Skip to content

Email 6-Digit Code Sign-In (OTP)

A passwordless sign-in path that emails the user a 6-digit code to type instead of a magic link to click. This 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 it opens a Supabase magic link to β€œscan” it, GoTrue consumes the one-time token β€” so by the time the user clicks, the link is already spent and they see otp_expired / access_denied. There is nothing we can do server-side to stop a scanner from following a link.

A typed 6-digit code sidesteps the problem entirely: it is inert text the scanner cannot β€œclick,” and verification is a separate GoTrue path from the PKCE ?code= link exchange. The user reads the number and types it into the form.

We keep the magic link as the default for everyone else; the code is an opt-in fallback offered on the login screen (β€œEmail me a 6-digit code instead”) and auto-recommended in the error hint after a burned-link failure.

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 the {{ .ConfirmationURL }} link as a styled fallback button. Notes on the design:

    • Canonical product name TheAccessiblePDF β€” 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 #054fb9 (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;">TheAccessiblePDF</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 TheAccessiblePDF. Go back to the
    sign-in page in your browser β€” the tab where you asked for a code β€”
    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:#eff6ff;border:1px solid #b8d0f8;border-radius:8px;text-align:center;">
    <span style="font-size:34px;font-weight:700;letter-spacing:8px;color:#054fb9;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
    tab, return to the sign-in page, choose &ldquo;Email me a 6-digit
    code,&rdquo; enter your email, and type this code.
    </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:#054fb9;">
    <a href="{{ .ConfirmationURL }}" 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.theaccessible.org/privacy" style="color:#6b7280;text-decoration:underline;">Privacy Policy</a>
    &nbsp;&middot;&nbsp;
    <a href="https://pdf.theaccessible.org/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 TheAccessiblePDF sign-in code is {{ .Token }}. Enter it on the sign-in page (the tab where you requested it). 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;">TheAccessiblePDF</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 TheAccessiblePDF
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:#054fb9;">
<a href="{{ .ConfirmationURL }}" 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.theaccessible.org/auth/login" style="color:#054fb9;text-decoration:underline;">sign-in page</a>
and choose &ldquo;Email me a 6-digit code.&rdquo;
</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 (TheAccessible.org 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.theaccessible.org/privacy" style="color:#6b7280;text-decoration:underline;">Privacy Policy</a>
&nbsp;&middot;&nbsp;
<a href="https://pdf.theaccessible.org/support" style="color:#6b7280;text-decoration:underline;">Support</a>
</p>
<p style="margin:0;font-size:13px;color:#6b7280;">&copy; 2026 TheAccessible.org. 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

Link-based confirmation (Supabase {{ .ConfirmationURL }}). Keep it link-based β€” there is no signup-code entry screen β€” but fix the name, off-brand button color, and AnglinAI footer, and add the passwordless escape hatch. (Signing in with a 6-digit code also verifies the email, so it’s a genuine alternative when a corporate scanner burns the confirm link.) 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;">TheAccessiblePDF</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 TheAccessiblePDF. Click the button below to
verify your email address and activate your account.
</p>
<!-- Primary CTA -->
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px 0;">
<tr>
<td style="border-radius:8px;background-color:#054fb9;">
<a href="{{ .ConfirmationURL }}" 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="{{ .ConfirmationURL }}" style="color:#054fb9;word-break:break-all;">{{ .ConfirmationURL }}</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.theaccessible.org/auth/login" style="color:#054fb9;text-decoration:underline;">sign-in page</a>
and choose &ldquo;Email me a 6-digit code.&rdquo; 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 (TheAccessible.org 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.theaccessible.org/privacy" style="color:#6b7280;text-decoration:underline;">Privacy Policy</a>
&nbsp;&middot;&nbsp;
<a href="https://pdf.theaccessible.org/support" style="color:#6b7280;text-decoration:underline;">Support</a>
</p>
<p style="margin:0;font-size:13px;color:#6b7280;">&copy; 2026 TheAccessible.org. 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 }}, {{ .ConfirmationURL }}) resolve the same way in the hook payload. Keep every line under ~78 chars and avoid trailing spaces.

TheAccessiblePDF β€” your sign-in code
We received a request to sign in to TheAccessiblePDF.
Your 6-digit code is: {{ .Token }}
Go back to the sign-in page in your browser β€” the tab where you asked for a
code β€” and type it into the "6-digit code" box. This code expires in 1 hour
and can be used once.
If you closed that tab, return to the sign-in page, choose "Email me a
6-digit code," enter your email, and type this code.
Prefer a link? Some corporate email systems break sign-in links, so the code
above is more reliable. Link: {{ .ConfirmationURL }}
If you didn't request this email, you can safely ignore it. No changes will
be made to your account.
Privacy: https://pdf.theaccessible.org/privacy
Support: https://pdf.theaccessible.org/support
(c) 2026 TheAccessible.org. All rights reserved.

Reset Password

TheAccessiblePDF β€” reset your password
We received a request to reset the password for your TheAccessiblePDF
account. Open this link to choose a new password (expires in 1 hour, single
use):
{{ .ConfirmationURL }}
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.theaccessible.org/auth/login and choose "Email me a 6-digit
code."
If you didn't request a password reset, you can safely ignore this email.
Your password will remain unchanged.
Privacy: https://pdf.theaccessible.org/privacy
Support: https://pdf.theaccessible.org/support
(c) 2026 TheAccessible.org. All rights reserved.

Confirm Signup

TheAccessiblePDF β€” confirm your email address
Thanks for signing up for TheAccessiblePDF. Open this link to verify your
email address and activate your account:
{{ .ConfirmationURL }}
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.theaccessible.org/auth/login and choose "Email me a 6-digit
code." Signing in verifies your email too.
If you didn't create an account, you can safely ignore this email.
Privacy: https://pdf.theaccessible.org/privacy
Support: https://pdf.theaccessible.org/support
(c) 2026 TheAccessible.org. 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.
  • packages/accessible-org-ui/src/components/LoginPage.tsx β€” emailOtp prop + β€œEmail me a 6-digit code instead” toggle + burned-link error hint.
  • apps/web/src/app/auth/login/page.tsx β€” passes emailOtp={{ onRequestCode: signInWithEmail, onVerify: verifyEmailOtp }}.

Enabling it in other apps

Every app uses the shared SharedAuthPage/LoginPage and the base auth provider, so verifyEmailOtp is already available on their useAuth(). To turn the feature on for another app, add one prop to that app’s login page:

emailOtp={{ onRequestCode: signInWithEmail, onVerify: verifyEmailOtp }}