AUTH_ALLOWED_EMAILS: named sign-in exceptions, so the domain rule cannot become a lock-out - #36
Conversation
The super-admin floor exists so nobody can be locked out permanently, but isSuperAdminFloor calls isAllowedEmail first, so a floor entry on any other domain was silently ignored: the environment variable looked set and did nothing. That is the worst shape a safety net can have, and it bites exactly the accounts that need it — the people holding super_admin are the ones whose institutional address is temporary. isAllowedEmail now admits an address on the configured domain or one named in AUTH_ALLOWED_EMAILS. Named exceptions, never a second open domain: one entry admits one person, and adding one is a deliberate act by somebody who can already deploy. Empty by default, where it changes nothing. Setting it drops the Google hd hint, and that is not optional. Better Auth verifies the hd claim on the returned id token and a personal account carries none, so leaving it on would refuse every named exception before this app's own check ran. The picker widens; the two server-side checks do not move. Verified with the credentials unset: 97 files / 1362 tests, typecheck clean, spec:coverage 74 items / 0 undocumented. The new tests assert the part that matters — naming one gmail address does not admit a second one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 03e6861e3f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| export function createAuth(db: Db) { | ||
| const secret = process.env.AUTH_SECRET || ""; | ||
| const domain = allowedEmailDomain(); | ||
| const namedExceptions = allowedEmails().length > 0; |
There was a problem hiding this comment.
Add the allowlist to the auth-instance fingerprint
When AUTH_ALLOWED_EMAILS changes after getAuth() has memoized an instance, the new value changes namedExceptions and therefore whether Better Auth receives hd, but envFingerprint() does not include this variable. The cached instance can consequently retain the institutional hd check after an off-domain exception is enabled, causing Google to reject that named account before isAllowedEmail() runs. Include AUTH_ALLOWED_EMAILS in the fingerprint so configuration changes rebuild the instance as intended.
Useful? React with 👍 / 👎.
| # Addresses allowed in BY NAME, whatever domain they are on — comma-separated. | ||
| # Named exceptions, never a second open domain: for a maintainer or a director | ||
| # whose institutional account is temporary. Setting any value here drops the | ||
| # Google `hd` hint, because Better Auth verifies that claim and a personal | ||
| # account carries none. Empty (the Cornell Tech deployment's setting) leaves | ||
| # the domain rule exactly as it was. | ||
| AUTH_ALLOWED_EMAILS= |
There was a problem hiding this comment.
Update the canonical auth guidance for the new allowlist
Document this variable in the scoped v5 guidance and the Stage 3 deployment instructions: the former still says AUTH_SUPER_ADMIN_EMAILS is the one remaining environment list, while the latter omits AUTH_ALLOWED_EMAILS entirely. An operator following those canonical instructions cannot configure the off-domain floor that this change is intended to support, leaving the lock-out recovery procedure incomplete.
AGENTS.md reference: v5/AGENTS.md:L109-L114
Useful? React with 👍 / 👎.
Stacked on #35.
The bug this closes
The super-admin floor exists so nobody can be locked out permanently. But
isSuperAdminFloorcallsisAllowedEmailfirst:So a floor entry on any other domain was silently ignored. You would set
AUTH_SUPER_ADMIN_EMAILSto a personal address, redeploy, sign in, and be bounced to/auth/rejectedwith no indication why — the env var looked configured and did nothing. That is the worst possible shape for a safety net, and it fails exactly the accounts that need it: the people holdingsuper_adminare the ones whose institutional address is temporary.The change
AUTH_ALLOWED_EMAILS— a comma-separated list of full addresses admitted whatever domain they are on.isAllowedEmailnow passes an address on the configured domain or one named in the list.Named exceptions, never a second open domain. One entry admits one person, not a provider. Adding one is a deliberate act by somebody who can already deploy. Empty by default, and empty leaves the domain rule byte-identical to before.
One consequence, stated plainly
Setting this drops the Google
hdhint, and that is not optional.hdis not only a hint to the account picker — Better Auth verifies the claim on the returned id token, and a personal Google account carries nohdat all. Left on, it would refuse every named exception before this app's own check ever ran, and the allowlist would look configured and do nothing. Again.So
socialProviders.google.hdis omitted whenever the list is non-empty. The account picker gets wider. The two server-side enforcement points in §3.4 do not move, and they are the control —hdnever was.Tests
The one that matters is the third: naming one Gmail address must not admit a second one.
steinbergisaac@gmail.comdoes not admitsomeone.else@gmail.comVerification
With every credential unset: typecheck clean, 97 files / 1362 tests,
spec:coverage74 items / 0 undocumented. The coverage gate caught the undocumented env var before I did, which is the gate working.Bootstrap plan
AUTH_SUPER_ADMIN_EMAILS=ies22@cornell.edu— the floor at launch, works with no code changeAUTH_ALLOWED_EMAILS=steinbergisaac@gmail.com— so access survives the Cornell account🤖 Generated with Claude Code
https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE