Plan: Email OTP Login for Non-Illinois Users
Implement the following plan:
Plan: Email OTP Login for Non-Illinois Users
Goal
Add email OTP (one-time password) as a second login method alongside Microsoft/Azure AD, so non-Illinois users can authenticate.
Current State
- Better Auth v1.4.18 with Microsoft as the only social provider
- Login page auto-redirects to Microsoft — no provider choice
- Mail relay already configured (Illinois outbound relay, nodemailer in
apps/mail) verificationstable already exists in the DB schema (used by Better Auth)MAIL_*env vars already inturbo.jsonglobalPassThroughEnv
Approach
Better Auth has a first-party emailOTP plugin that handles OTP generation, storage (in the verifications table), and sign-in. We add this plugin server-side with a sendVerificationOTP callback that sends via the existing relay, register the client plugin, and redesign the login page to offer both methods.
No database migrations needed — the plugin uses the existing verifications table.
Steps
Step 1: Add nodemailer to packages/auth
File: packages/auth/package.json
- Add
"nodemailer": "7.0.13"to dependencies - Add
"@types/nodemailer": "7.0.9"to devDependencies - Run
bun install
Step 2: Add emailOTP plugin to auth config
File: packages/auth/src/config.ts
- Import
emailOTPfrom"better-auth/plugins"andnodemailer - Create a relay transport (same pattern as
apps/mail/src/sender.ts): host fromMAIL_RELAY_HOST, port fromMAIL_RELAY_PORT, no TLS - Derive
MAIL_DOMAINfrom env for thefromaddress (noreply@{MAIL_DOMAIN}) - Add
plugins: [emailOTP({ ... })]with:sendVerificationOTPcallback that sends via the relay transportotpLength: 6,expiresIn: 300(5 min)disableSignUp: false(allow new accounts for non-Illinois users)
- Add
"email-otp"toaccount.accountLinking.trustedProvidersfor bidirectional linking
Step 3: Register client plugin
File: apps/web/lib/auth-client.ts
- Import
emailOTPClientfrom"better-auth/client/plugins" - Add
plugins: [emailOTPClient()]to thecreateAuthClientcall
Step 4: Redesign login page
File: apps/web/app/login/page.tsx
Replace auto-redirect with a three-state UI:
- Choose — two buttons: "Sign in with Illinois (Microsoft)" and "Sign in with email code", separated by an "or" divider
- OTP: email — email input + "Send sign-in code" button + back link
- OTP: code — 6-digit numeric input + "Verify and sign in" button + "Change email" / "Resend code" links
Uses existing Button, Input, Card components. All interactive elements get data-testid attributes.
Files Modified
packages/auth/package.json— add nodemailer depspackages/auth/src/config.ts— emailOTP plugin + relay transportapps/web/lib/auth-client.ts— emailOTPClient pluginapps/web/app/login/page.tsx— redesigned login UI
What Does NOT Change
- Database schema (no migrations)
- API route handler (
apps/web/app/api/auth/[...all]/route.ts) — plugin auto-registers endpoints turbo.json—MAIL_*already covered- Environment files —
MAIL_RELAY_HOST,MAIL_RELAY_PORT,MAIL_DOMAINalready set
Verification
bun installsucceedsbunx turbo tsc --filter=@repo/web --filter=@repo/authpasses- Visit
/login— see both sign-in options - Enter an email → receive OTP email → enter code → signed in
- Illinois users can still sign in via Microsoft button
- Check account linking: Illinois user signing in via email with their @illinois.edu address links to existing account
If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ~/.claude/projects/-Users-challen-www-usingandunderstanding-ai/c7022a9f-d659-463f-a413-011e07a97fe8.jsonl
I'll start by reading the key files I need to modify, then implement the changes.
Let me also check the existing mail sender pattern and the UI components available.
Good, I have all the context I need. Let me also quickly check the Input component and the existing nodemailer versions to ensure exact matches.
Now let me create the task list and start implementing.
Step 1: Add nodemailer dependencies to packages/auth
Already resolved from the lockfile (used elsewhere in the monorepo).
Step 2: Add emailOTP plugin to auth config
Now add the relay transport and the plugin configuration.
Now add the plugins array and update trustedProviders.
Step 3: Register emailOTPClient plugin in auth-client
Step 4: Redesign login page
Let me check what UI components are available (Label, etc.) before writing the login page.
No Label component — I'll use plain labels or skip them. Now let me write the redesigned login page.
Step 5: Verify TypeScript compilation
Both packages compile cleanly.
All done. Here's a summary of the changes:
4 files modified:
-
packages/auth/package.json— Addednodemailer(7.0.13) and@types/nodemailer(7.0.9) -
packages/auth/src/config.ts— Added theemailOTPplugin with:- A nodemailer relay transport using
MAIL_RELAY_HOST/MAIL_RELAY_PORTenv vars sendVerificationOTPcallback that sends a code via the relay- 6-digit codes, 5-minute expiry, sign-up enabled
"email-otp"added totrustedProvidersfor account linking
- A nodemailer relay transport using
-
apps/web/lib/auth-client.ts— RegisteredemailOTPClient()plugin -
apps/web/app/login/page.tsx— Replaced auto-redirect with three-step UI:- Choose — "Sign in with Illinois (Microsoft)" and "Sign in with email code" buttons
- Email — email input + send code button
- Code — 6-digit OTP input + verify button, with change email and resend links
- All interactive elements have
data-testidattributes
No database migrations needed — the plugin uses the existing verifications table.
Your task
First, run git status, git diff, and git log --oneline -10 to understand the current state.
Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.
No untracked files, no debugging statements or unnecessary logging in the diff. The changes are clean — all 5 modified files are part of a single coherent feature. This fits well as a single commit.
Committed as 9ba0033. Working tree is clean. The branch is 1 commit ahead of origin — let me know if you'd like to push.
OK. So, to be clear, this sends an email with a code which the person has to type in. Why can't we use a magic link? Is that an option? That would be my preference.
(no content)
Magic link is a better UX and Better Auth has a first-party magicLink plugin that works the same way — uses the verifications table, sends via the same mail relay. The user just clicks a link in their email instead of copying a code.
The only tradeoff: magic links require the user to open the link in the same browser (or at least the same device) where they initiated login. OTP is device-agnostic — you can read the code on your phone and type it on your laptop. In practice, magic links are fine for most users and feel more seamless.
Want me to switch from emailOTP to magicLink? The changes would be in the same 4 files — swap the plugin, simplify the login page (removes the code-entry step entirely), and update the email to contain a link instead of a code.
Yeah, I think magic links are fine.
(no content)
Let me check the Better Auth magicLink plugin API and then plan the swap.
I have all the details. The swap is straightforward. Let me read the existing plan and update it.