Skip to content

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.)

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 the next= position, so a second &-separated param inside it would bleed into the outer confirm URL and break next parsing. All current redirectTo values (/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.

  1. Supabase Dashboard β†’ Authentication β†’ Email Templates β†’ Magic Link.

  2. 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 the
    sign-in page in your browser and type this code into the
    <strong>&ldquo;6-digit code&rdquo;</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 the
    sign-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, so
    the 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&rsquo;t request this email, you can safely ignore it. No
    changes 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>
    &nbsp;&middot;&nbsp;
    <a href="https://pdf.gevell.com/support" style="color:#6b7280;text-decoration:underline;">Support</a>
    </p>
    </div>
    </div>
    </div>
  3. 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 signInWithOtp login path. Password reset (β€œReset Password” template), signup confirmation (β€œConfirm signup”), OAuth, and voice OTP (Send SMS Hook) use other templates/flows. The two server-side generateLink({ 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/callback handler 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-code page 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&rsquo;t work? Your company&rsquo;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&rsquo;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>
&nbsp;&middot;&nbsp;
<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;">&copy; 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&rsquo;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&rsquo;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&rsquo;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>
&nbsp;&middot;&nbsp;
<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;">&copy; 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.

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-digit
code" 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 code
above 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 will
be made to your account.
Privacy: https://pdf.gevell.com/privacy
Support: 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 PDF
account. Open this link to choose a new password (expires in 1 hour, single
use):
{{ .SiteURL }}/auth/confirm/?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}
Using a work email and the link doesn't work? Your company's security
scanner may have opened it first. You can skip the password entirely β€” go to
https://pdf.gevell.com/auth/login and type the 6-digit code from the
sign-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/privacy
Support: 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 your
email 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 security
scanner may have opened it first. You can also just sign in β€” go to
https://pdf.gevell.com/auth/login and type the 6-digit code from the
sign-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/privacy
Support: 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) (verifyOtp with type: 'email'), exposed on the auth context.
  • packages/accessible-org-ui/src/components/EmailOtpLogin.tsx β€” two-step (email β†’ code) UI, mirrors VoiceOtpLogin. With sentTo it opens straight on code entry (the caller already sent the email).
  • packages/accessible-org-ui/src/components/LoginPage.tsx β€” emailOtp prop + code box on the β€œCheck Your Email” screen (via EmailOtpLogin’s sentTo prop) + 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/purchase pages) passes emailOtp={{ onRequestCode: signInWithEmail, onVerify: verifyEmailOtp }}. The first email goes out via onSignInWithEmail; onRequestCode is 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 }}