Thinking Out Loud

The Email Allowlist I Deleted, and Why I Put It Back

Anthony Shead/September 14, 2026/5 min read
The Email Allowlist I Deleted, and Why I Put It Back
fig. 1 - The Email Allowlist I Deleted, and Why I Put It Back

The first version of admin access in this codebase was an environment variable called ADMIN_EMAILS. A comma-separated string. Hit an admin route, split the string, and check whether the signed-in user's email was in it.

It's what you write in week one because it works and you have forty other problems. It also can't distinguish tiers of access, and adding a person means editing an environment variable and redeploying the whole application.

So I deleted it and built the real thing. Then months later I deliberately put an email comparison back into the codebase. Same primitive, completely different job. That's what this post is about.

Why Clerk. I started on Auth.js and have nothing bad to say about it. But with five Next.js apps marketing, docs, blog, dashboard, admin all deploying to Cloudflare Workers, wrangling session storage and edge compatibility across five distinct apps became more operational surface area than the auth problem was worth. I pulled the Auth.js config out and committed to Clerk as the sole provider.

One package, one door. The real failure mode in a five-app monorepo isn't choosing the wrong provider. It's five apps importing it directly with slightly different configuration until they drift so far apart that changing anything means auditing five codebases.

So every Clerk import in the repository lives inside a single package, @soren/auth. No application code touches the SDK directly. Inside it, package.json export paths enforce boundaries: the default export is client-side React components, ./server holds server-only session helpers that are structurally blocked from client imports, and ./middleware handles app-level routing.

That export separation is the part that matters most and gets discussed least. Server secrets can't leak into a browser bundle when the import path that reaches them doesn't exist on the client. The side benefit is that migrating off Clerk someday means changing one directory instead of five applications.

SSO, mostly for free. Locally, each app's .env symlinks back to one root .env with shared keys, and since browser cookies aren't port-scoped, SSO works across all five dev servers with no effort. In production, it's DNS: each app on its own subdomain under one apex, and Clerk scopes sessions to the parent domain, so authenticated state flows everywhere automatically.

Roles at the edge. Replacing the allowlist meant real RBAC: admin for CRUD on clients, projects, contacts,s and content, owner for that plus team role management.

The obvious implementation looks up the role on every protected request. But I'd just spent weeks moving to Cloudflare Workers for edge latency, and an API round-trip per request hands all of it back. So instead of looking the role up, I put it in the token. Clerk's CLI configures custom session claims that copy publicMetadata.role into the JWT every time a token is issued:

bash
npx clerk@latest config patch \
  --app app_xxxxxxxxxxxxx \
  --instance prod \
  --json '{"session":{"claims":{"metadata":{"role":"{{user.public_metadata.role}}"}}}}'

Now the role arrives inside a signed token the request was already carrying. Reading it costs nothing. Zero database queries, zero API calls.

The door that locks behind you. Roles now live in Clerk's metadata, which means if that metadata is wiped, misconfigured, or if I spin up a fresh development instance and forget to configure session claims, the role resolves to null, and I'm locked out of my own admin panel with the tool I'd use to fix the permissions sitting behind the permissions.

That's the hazard in any system where the authority to grant access is stored in the same place as the access. So there's a bootstrap path: an out-of-band way for one identity to be recognized as owner without depending on metadata that could be corrupted. Server-side only, inside the compiled edge code.

I'm not publishing the mechanism, and not because it's weak. Every bootstrap is, by definition,n a path that bypasses the normal checks; that's what makes it a bootstrap. The design doesn't get stronger by being described in public. The architectural lesson transfers regardless: if your permission system can lose its own permissions, you need a recovery path that doesn't depend on it.

Which is the loop. The allowlist I deleted for being unscalable was, functionally, a very simple recovery path. It was genuinely bad as a primary access system. But the primitive was never the problem; using it as the entire building was.

What I'm not paying for yet. Clerk gates MFA behind a $25/month Pro tier, and the same rule from last post applies: no fixed recurring costs before revenue. This one bothers me more than the Vercel decision did, because what I'm deferring is a security control rather than a convenience.

What I did instead was layer free defenses. Cloudflare Access sits in front of the admin subdomain with an email OTP challenge, so the admin app isn't publicly reachable at all. Clerk handles identity behind that. And every server-side mutation and layout in the admin app calls requireAdmin() or requireOwner() on every request, with no cached authorization.

Three gates instead of one, all on free tiers. The honest limitation is that layered defenses are only as independent as what they depend on, and more of mine route through email than I'd like. It's written down, and MFA goes to the top of the list the month revenue starts.

The full version is on Patreon. Everything essential is here.

New posts, in your inbox

Roughly one a week. Confirm once, unsubscribe anytime.

Writing like this is reader-supported.Support on Patreon →

0 comments

Read next