AK / PORTFOLIOWriting
5 min

Two auth systems, one webapp.

The moment your web app has both human users and machine callbacks, one auth strategy stops fitting. Trying to force the fit is where I burned a week.

I used to think auth was one problem. You set up a login, you validate a token on every request, done. Then I built a web app where an LLM agent had to call back into my own API to run tools, and the whole mental model fell apart in about an afternoon.

The audience split you don't notice until it's too late

Every real web app I've worked on has two audiences, whether you notice or not:

  1. Humans in browsers. They authenticate once via SSO, get a session, and expect the browser to remember them. Auth for them is implicit — a cookie, an identity header, a token silently attached to every request by infrastructure they never see.
  2. Machines calling back. Webhooks. LLM tool callbacks. CI runners. Cron jobs. Other services. They authenticate explicitly — a shared secret, a signed request, a client certificate. Whatever it is, the machine has to hand it over on every call because it has no session.

Trying to serve both with one mechanism is where teams get in trouble. Session cookies don't make sense for a webhook. API keys are terrible for browser users (rotate the key and you've logged everyone out). Bearer tokens for a browser user require a token flow that's way more infra than a small app needs.

The insight I wish I'd had earlier: the two audiences don't share a threat model, so they shouldn't share an auth path.

Two layers, cleanly separated

The shape that worked, once I stopped fighting it:

  • A global auth layer for humans. Runs on every request. Validates the user's SSO token if present, injects identity headers for downstream code to read, and permits anonymous traffic only on the specific routes that machines call. That last part is important — it's tempting to just require auth on everything, but you cannot, because your callbacks aren't logged-in humans.
  • A route-scoped middleware for machines. Wraps only the callback endpoints. Reads a shared secret from a header, compares it — with constant-time comparison, not == — to the expected value stored in an app setting. Nothing else on the app runs this middleware.

Two layers. Clean split. Machines can't accidentally access human-only routes because the SSO layer would need a valid user token. Humans can't accidentally trigger machine routes because they don't have the secret.

The little things I got wrong

  • Reused == for the secret compare. Timing side-channels are real. Small comparisons can leak the first-mismatched byte over enough requests. Language runtimes ship a constant-time compare for exactly this. Use it.
  • Put the secret in an environment variable and left it in a .env file. Then I committed it. Then I unwrote history. Now the secret lives only in the cloud config, and my local dev uses a different, disposable value.
  • Assumed machine identity = human identity. For outbound calls to other services, use a managed identity (or its equivalent on your cloud) — not a service-account token you have to rotate. Managed identities are the closest thing to auth that just works.

The multi-identity trap

If your web app is doing outbound work as more than one identity — a common shape when a single service straddles two products or two subscriptions — you also have to explicitly select which identity you want. The SDK will not guess for you, and it will pick wrong — silently — if you leave it to defaults.

The fix is one env var that names the exact client ID you want the SDK to use. If you don't set it, and you have two identities attached, half your outbound calls will succeed and half won't, and the failure will look like a permissions bug for weeks.

What I'd tell past-me

  • Audit who calls your API. If any of them are machines, plan for two auth paths from day one, not later.
  • Route-scope the machine middleware. Do not global-install it.
  • Use constant-time compare for shared secrets. Every language has one.
  • Route outbound calls through a managed identity. Don't manage service-account credentials by hand.
  • If you have more than one identity attached, name the one you want explicitly — and log which one you resolved on every outbound call for a week, until you trust it.

Auth is not one problem. It's a small family of them wearing similar clothes, and the moment you notice they don't share a threat model, the design writes itself.

Command palette

Search and jump to any page.