Email 6-Digit Code Sign-In (OTP)
Every passwordless sign-in email carries both a magic link and a 6-digit code; the user can type the code instead of clicking the link. The code is the reliable option for corporate inboxes.
Why this exists
Enterprise mail security (Proofpoint, Mimecast, Microsoft Defender Safe Links,
Barracuda, etc.) rewrites and pre-fetches URLs in inbound email. When a
link verifies on GET, the scannerβs prefetch consumes the one-time token β so
by the time the user clicks, the link is already spent and they see
otp_expired / access_denied.
A typed 6-digit code is inert text the scanner cannot βclick.β But the code and the clickable link in the same email share ONE underlying token, so as long as the link verified on GET, a prefetch burned the typed code too. The code is only genuinely scanner-proof now that the links are gesture-gated β see the #2256 section below, which is the server-side half of this defense.
There is one sign-in email, carrying both the link and the code (Supabase
canβt send different templates for βlinkβ vs βcodeβ β both are the same
signInWithOtp call). So the login screen offers a single βEmail Me a Sign-In
Linkβ button, and the βCheck Your Emailβ screen that follows shows a 6-digit
code box: the user clicks the link or types the code, whichever works.
The burned-link error hint points users at the code. (Until 2026-09-26 the
code was a separate βEmail me a 6-digit code insteadβ mode, which confused
users because the link-mode email contained a code too.)
Confirm-on-gesture links (issue #2256, 2026-08-24)
All email links now point at {{ .SiteURL }}/auth/confirm/ with a
token_hash instead of the direct {{ .ConfirmationURL }}. The old links
verified on GET, so a mail gatewayβs prefetch consumed the one-time token
before the user ever saw the email β and because the clickable link and the
6-digit {{ .Token }} share the SAME token, the scanner burned the typed
code too (observed for usd.edu: every token verified 60-90 s after send,
while delivery took ~4 min).
The confirm page renders a Continue button and spends the token only on
the button press (verifyOtp({ token_hash, type })), which a scanner
never performs. type is email / recovery / signup per template.
next carries {{ .RedirectTo }} and is validated to https URLs on
gevell.com (apex or subdomain) or same-app relative paths β an
open-redirect guard; a sessionStorage postLoginRedirect takes precedence.
Cross-app sign-ins work because the session cookie is shared on
.gevell.com.
{{ .RedirectTo }}must stay a single-query-param URL. It is embedded unencoded in thenext=position, so a second&-separated param inside it would bleed into the outer confirm URL and breaknextparsing. All currentredirectTovalues (/auth/callback,/auth/callback?type=recovery) satisfy this.
Dashboard prerequisites (Authentication β URL Configuration): the
project Site URL must be https://pdf.gevell.com (that app hosts
/auth/confirm/), and each appβs /auth/callback stays in the redirect
allowlist so {{ .RedirectTo }} resolves.
How it works
- The same
signInWithOtp({ email })call powers both paths β Supabaseβs OTP email carries a magic link and a{{ .Token }}6-digit code. - The code-entry UI calls
supabase.auth.verifyOtp({ email, token, type: 'email' }). This works regardless of the clientβs PKCE flow type (PKCE only governs the link/?code=exchange). - Default code lifetime is 1 hour and single-use (Supabase Auth β OTP expiry; can be lowered to e.g. 600s).
Required Supabase Dashboard step (one-time)
The code path only works if the Magic Link email template actually renders
the token. Add {{ .Token }} to the template body. Keep the link too so
existing magic-link users are unaffected.
-
Supabase Dashboard β Authentication β Email Templates β Magic Link.
-
Replace the body with the template below. It leads with the code (the reliable path) and keeps a link as a styled fallback button. Notes on the design:
- Canonical product name
Gevell PDFβ never the retired βAccessible PDF Converterβ (project naming rule). - One expiry value. Supabase email-OTP default is 1 hour β donβt reintroduce a β24 hoursβ line; it contradicts the real token lifetime.
- Tells the user where to enter the code (back on the sign-in page).
- Blue accent
#003DA5(AAA on white); fallback is a 44px-tall button.
<div style="background-color:#f8fafc;padding:40px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;"><div style="max-width:600px;margin:0 auto;background-color:#ffffff;border-radius:8px;overflow:hidden;"><!-- Header --><div style="padding:32px 40px 24px 40px;border-bottom:1px solid #e2e8f0;"><span style="font-size:22px;font-weight:700;color:#1f2937;">Gevell PDF</span></div><!-- Body --><div style="padding:40px;"><h2 style="margin:0 0 16px 0;font-size:24px;font-weight:600;color:#1f2937;">Your sign-in code</h2><p style="margin:0 0 24px 0;font-size:16px;line-height:1.6;color:#1f2937;">We received a request to sign in to Gevell PDF. Go back to thesign-in page in your browser and type this code into the<strong>“6-digit code”</strong> box:</p><!-- The code (hero) --><div style="margin:0 0 8px 0;padding:20px;background-color:#EEF4FE;border:1px solid #b8d0f8;border-radius:8px;text-align:center;"><span style="font-size:34px;font-weight:700;letter-spacing:8px;color:#003DA5;font-family:'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;">{{ .Token }}</span></div><p style="margin:0 0 32px 0;font-size:14px;line-height:1.5;color:#6b7280;">This code expires in 1 hour and can be used once. If you closed thesign-in tab, use the button below instead.</p><!-- Fallback link (secondary) --><p style="margin:0 0 12px 0;font-size:14px;line-height:1.5;color:#6b7280;">Prefer a link? Some corporate email systems break sign-in links, sothe code above is more reliable β but you can also:</p><table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 8px 0;"><tr><td style="border-radius:8px;background-color:#003DA5;"><a href="{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}" style="display:inline-block;min-width:200px;padding:14px 28px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;text-align:center;">Sign in with a link</a></td></tr></table><!-- Security note --><p style="margin:24px 0 0 0;font-size:14px;line-height:1.5;color:#6b7280;border-top:1px solid #e2e8f0;padding-top:24px;">If you didn’t request this email, you can safely ignore it. Nochanges will be made to your account.</p></div><!-- Footer --><div style="padding:24px 40px;background-color:#f8fafc;border-top:1px solid #e2e8f0;text-align:center;"><p style="margin:0;font-size:13px;color:#6b7280;"><a href="https://pdf.gevell.com/privacy" style="color:#6b7280;text-decoration:underline;">Privacy Policy</a> · <a href="https://pdf.gevell.com/support" style="color:#6b7280;text-decoration:underline;">Support</a></p></div></div></div> - Canonical product name
-
Save. No redeploy of the apps is required for the template change itself. Also set a matching plain-text version in the templateβs text field (global email standard) β e.g.
Your Gevell PDF sign-in code is {{ .Token }}. Enter it on the sign-in page, or open this link: {{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }} Expires in 1 hour.
Verified safe: the Magic Link template is consumed only by the email
signInWithOtplogin path. Password reset (βReset Passwordβ template), signup confirmation (βConfirm signupβ), OAuth, and voice OTP (Send SMS Hook) use other templates/flows. The two server-sidegenerateLink({ type: 'magiclink' })callers β LTI (workers/api/src/routes/lti.ts) and the promo sign-in (workers/api/src/routes/promo.ts) β never make Supabase send email; they extract the token/action_link and either verify it in-process or dispatch their own email via Resend, so they donβt render this template either. The/auth/callbackhandler reads the?code=query param, not the template body, so the link keeps working.
Sibling template: Reset Password
Do NOT paste the 6-digit {{ .Token }} code into the Reset Password
template. The appβs recovery flow is link-only: the recovery link lands on
/auth/callback?type=recovery, which forwards to /auth/update-password and
calls updateUser({ password }). There is no recovery-code entry screen, so a
code in this email points nowhere. (Building verifyOtp({ type: 'recovery' })
- a
/auth/reset-codepage is a possible future task; only then would a code belong here.)
Because the recovery link has the same corporate-scanner risk as the magic link, the reset email should give those users an escape hatch: skip the password and sign in passwordlessly with a code. Corrected template:
<div style="background-color:#f8fafc;padding:40px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;"> <div style="max-width:600px;margin:0 auto;background-color:#ffffff;border-radius:8px;overflow:hidden;"> <!-- Header --> <div style="padding:32px 40px 24px 40px;border-bottom:1px solid #e2e8f0;"> <span style="font-size:22px;font-weight:700;color:#1f2937;">Gevell PDF</span> </div>
<!-- Body --> <div style="padding:40px;"> <h2 style="margin:0 0 16px 0;font-size:24px;font-weight:600;color:#1f2937;">Reset your password</h2> <p style="margin:0 0 28px 0;font-size:16px;line-height:1.6;color:#1f2937;"> We received a request to reset the password for your Gevell PDF account. Click the button below to choose a new password. This link expires in 1 hour and can be used once. </p>
<!-- Primary CTA --> <table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px 0;"> <tr> <td style="border-radius:8px;background-color:#003DA5;"> <a href="{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}" style="display:inline-block;min-width:200px;padding:14px 28px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;text-align:center;"> Reset your password </a> </td> </tr> </table>
<!-- Corporate-filter escape hatch --> <p style="margin:0 0 24px 0;font-size:14px;line-height:1.5;color:#6b7280;"> Using a work email and the button doesn’t work? Your company’s security scanner may have opened the link first. You can skip the password entirely β go to the <a href="https://pdf.gevell.com/auth/login" style="color:#003DA5;text-decoration:underline;">sign-in page</a> and type the 6-digit code from the sign-in email we send you. </p>
<!-- Security note --> <p style="margin:0;font-size:14px;line-height:1.5;color:#6b7280;border-top:1px solid #e2e8f0;padding-top:24px;"> If you didn’t request a password reset, you can safely ignore this email. Your password will remain unchanged. </p> </div>
<!-- Footer (Gevell branded β NOT AnglinAI) --> <div style="padding:24px 40px;background-color:#f8fafc;border-top:1px solid #e2e8f0;text-align:center;"> <p style="margin:0 0 8px 0;font-size:13px;color:#6b7280;"> <a href="https://pdf.gevell.com/privacy" style="color:#6b7280;text-decoration:underline;">Privacy Policy</a> · <a href="https://pdf.gevell.com/support" style="color:#6b7280;text-decoration:underline;">Support</a> </p> <p style="margin:0;font-size:13px;color:#6b7280;">© 2026 Gevell. All rights reserved.</p> </div> </div></div>Keep the expiry number (1 hour) in sync with the Auth Email OTP Expiration setting. Set a matching plain-text version too.
Sibling template: Confirm Signup
First-time passwordless sign-ins get THIS template, not Magic Link. When
signInWithOtp meets an address with no account, GoTrue creates the user and
sends Confirm signup. The login pageβs βCheck Your Emailβ screen promises a
link and a 6-digit code to everyone, so this template must carry
{{ .Token }} too β verifyOtp({ type: 'email' }) accepts the signup
confirmation token, so the code box works unchanged. The same template also
goes out for password sign-ups (web/vpat/admin), where there is no code box;
the copy below says βif youβre on the sign-in screenβ so that reads fine.
Corrected template:
<div style="background-color:#f8fafc;padding:40px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;"> <div style="max-width:600px;margin:0 auto;background-color:#ffffff;border-radius:8px;overflow:hidden;"> <!-- Header --> <div style="padding:32px 40px 24px 40px;border-bottom:1px solid #e2e8f0;"> <span style="font-size:22px;font-weight:700;color:#1f2937;">Gevell PDF</span> </div>
<!-- Body --> <div style="padding:40px;"> <h2 style="margin:0 0 16px 0;font-size:24px;font-weight:600;color:#1f2937;">Confirm your email address</h2> <p style="margin:0 0 28px 0;font-size:16px;line-height:1.6;color:#1f2937;"> Thanks for signing up for Gevell PDF. Click the button below to verify your email address and activate your account. </p>
<!-- Code (for the sign-in screen's 6-digit code box) --> <p style="margin:0 0 12px 0;font-size:16px;line-height:1.6;color:#1f2937;"> On the sign-in screen? You can type this code instead: </p> <div style="margin:0 0 28px 0;padding:20px;background-color:#EEF4FE;border:1px solid #b8d0f8;border-radius:8px;text-align:center;"> <span style="font-size:34px;font-weight:700;letter-spacing:8px;color:#003DA5;font-family:'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;">{{ .Token }}</span> </div>
<!-- Primary CTA --> <table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px 0;"> <tr> <td style="border-radius:8px;background-color:#003DA5;"> <a href="{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=signup&next={{ .RedirectTo }}" style="display:inline-block;min-width:200px;padding:14px 28px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;text-align:center;"> Confirm your email </a> </td> </tr> </table>
<!-- Copy-paste fallback --> <p style="margin:0 0 24px 0;font-size:14px;line-height:1.5;color:#6b7280;"> If the button doesn’t work, copy and paste this link into your browser:<br /> <a href="{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=signup&next={{ .RedirectTo }}" style="color:#003DA5;word-break:break-all;">{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=signup&next={{ .RedirectTo }}</a> </p>
<!-- Corporate-filter escape hatch --> <p style="margin:0 0 24px 0;font-size:14px;line-height:1.5;color:#6b7280;"> Using a work email and neither the button nor the link works? Your company’s security scanner may have opened the link first. You can also just sign in β go to the <a href="https://pdf.gevell.com/auth/login" style="color:#003DA5;text-decoration:underline;">sign-in page</a> and type the 6-digit code from the sign-in email we send you. Signing in verifies your email too. </p>
<!-- Security note --> <p style="margin:0;font-size:14px;line-height:1.5;color:#6b7280;border-top:1px solid #e2e8f0;padding-top:24px;"> If you didn’t create an account, you can safely ignore this email. </p> </div>
<!-- Footer (Gevell branded β NOT AnglinAI) --> <div style="padding:24px 40px;background-color:#f8fafc;border-top:1px solid #e2e8f0;text-align:center;"> <p style="margin:0 0 8px 0;font-size:13px;color:#6b7280;"> <a href="https://pdf.gevell.com/privacy" style="color:#6b7280;text-decoration:underline;">Privacy Policy</a> · <a href="https://pdf.gevell.com/support" style="color:#6b7280;text-decoration:underline;">Support</a> </p> <p style="margin:0;font-size:13px;color:#6b7280;">© 2026 Gevell. All rights reserved.</p> </div> </div></div>Plain-text versions
Global email standard requires a plain-text alternative for every email. Note:
Supabaseβs dashboard email templates are HTML-only β there is no plain-text
field in the UI, and GoTrueβs built-in mailer sends a single HTML part. To ship
a real multipart/alternative text part you must move sending to the Send
Email Hook (custom SMTP / Resend), where you emit both parts yourself; use the
bodies below as the text part there. Until then, keep these in sync with the
HTML so theyβre ready to drop in.
Placeholders ({{ .Token }}, {{ .TokenHash }}, {{ .RedirectTo }}) resolve the same way in
the hook payload. Keep every line under ~78 chars and avoid trailing spaces.
Magic Link (sign-in code)
Gevell PDF β your sign-in code
We received a request to sign in to Gevell PDF.
Your 6-digit code is: {{ .Token }}
Go back to the sign-in page in your browser and type it into the "6-digitcode" box. This code expires in 1 hour and can be used once.
If you closed the sign-in tab, use the link below instead.
Prefer a link? Some corporate email systems break sign-in links, so the codeabove is more reliable. Link: {{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}
If you didn't request this email, you can safely ignore it. No changes willbe made to your account.
Privacy: https://pdf.gevell.com/privacySupport: https://pdf.gevell.com/support(c) 2026 Gevell. All rights reserved.Reset Password
Gevell PDF β reset your password
We received a request to reset the password for your Gevell PDFaccount. Open this link to choose a new password (expires in 1 hour, singleuse):
{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}
Using a work email and the link doesn't work? Your company's securityscanner may have opened it first. You can skip the password entirely β go tohttps://pdf.gevell.com/auth/login and type the 6-digit code from thesign-in email we send you.
If you didn't request a password reset, you can safely ignore this email.Your password will remain unchanged.
Privacy: https://pdf.gevell.com/privacySupport: https://pdf.gevell.com/support(c) 2026 Gevell. All rights reserved.Confirm Signup
Gevell PDF β confirm your email address
Thanks for signing up for Gevell PDF. Open this link to verify youremail address and activate your account:
{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=signup&next={{ .RedirectTo }}
On the sign-in screen? You can type this code instead: {{ .Token }}
Using a work email and the link doesn't work? Your company's securityscanner may have opened it first. You can also just sign in β go tohttps://pdf.gevell.com/auth/login and type the 6-digit code from thesign-in email we send you. Signing in verifies your email too.
If you didn't create an account, you can safely ignore this email.
Privacy: https://pdf.gevell.com/privacySupport: https://pdf.gevell.com/support(c) 2026 Gevell. All rights reserved.Where itβs wired
packages/auth/src/create-auth-provider.tsxβverifyEmailOtp(email, code)(verifyOtpwithtype: 'email'), exposed on the auth context.packages/accessible-org-ui/src/components/EmailOtpLogin.tsxβ two-step (email β code) UI, mirrorsVoiceOtpLogin. WithsentToit opens straight on code entry (the caller already sent the email).packages/accessible-org-ui/src/components/LoginPage.tsxβemailOtpprop + code box on the βCheck Your Emailβ screen (viaEmailOtpLoginβssentToprop) + burned-link error hint.- Every appβs login page (admin, advising-worksheet, audit, course-map,
flowchart, forms, links, music, org-chart, photos, power, pro, remediate,
vpat, web β not the
/auth/login/purchasepages) passesemailOtp={{ onRequestCode: signInWithEmail, onVerify: verifyEmailOtp }}. The first email goes out viaonSignInWithEmail;onRequestCodeis only the sent screenβs Resend.
Enabling it in a new app
Every app uses the shared SharedAuthPage/LoginPage and the base auth
provider, so verifyEmailOtp is already available on its useAuth(). Add one
prop to the new appβs login page (it relabels the button βEmail Me a Sign-In
Linkβ, adds the link-or-code help line, and puts the code box on the sent
screen):
emailOtp={{ onRequestCode: signInWithEmail, onVerify: verifyEmailOtp }}