Back to Feed
article 3m read

The API was reading tokens it never bothered to check

The first version of my second login path decoded signed tokens and trusted whatever user ID was inside; now it checks the signature and issuer, but the audience, the pinned issuer and a test with a real untrusted key are still open.

Article DepthPractice

Flow

Fake tokens worth trying, and what each should get

  1. 1Signed by a stranger's key

    Same claims, signed with a key pair made in the test. Should get a 401.

  2. 2Wrong issuer

    Trusted key, unexpected issuer. Should get a 401.

  3. 3Expired

    Trusted key and issuer, but past its end time. Should get a 401.

  4. 4Made for another app

    A real token meant for a different service. Only an audience check would stop it.

  5. 5No signature at all

    A token that claims to need no signing. Should get a 401.

  6. 6Unknown user

    Good signature, but the user is missing here. Should get less, or nothing.

Anyone with a token whose user ID matched someone in my database used to get in through the API. On the token side, nothing else stood between a stranger and my API.

The API collects data from my coding agents. It takes two kinds of credential. One is its own API keys, stored as hashes and easy to revoke. The other is signed tokens from an outside login provider, for machines and services that already sign in there. The first version of the token code read the user and the organization out of the token and looked them up. It never checked the signature.

Why decoding was never authentication

A signed token has three parts. The first two are just JSON, lightly encoded, and anyone can write them. The third part, the signature, is the only proof of who actually made the token, and my code skipped it, so the real question was only whether the user ID existed. User IDs are not secrets. I have no sign anyone used this, but the door was open.

const forged = [enc({ alg: "RS256" }), enc({ sub: knownUserId }), "nope"].join(".");

Swapping the reader for a checker

Now the code checks the signature against the provider's published public keys, the token has to name the expected issuer, and anything that fails is turned away with a 401. After that, the old checks still run. A token with no user is refused, and so is a user my database doesn't recognize. A good signature means the provider vouched for the token. It doesn't mean I should hand anything over, so my own lookup stays.

Keys and tokens look alike downstream

The API picks a path by the token's shape. Keys with my prefix go one way, and everything else is treated as a signed token. Both paths end up looking the same to the rest of the code. That hides one difference: I can revoke a key myself, but I can't revoke a provider token before it runs out.

What it still misses

  • It doesn't check who the token was made for, so a token meant for another app could work here.
  • The expected issuer is fixed, and my two setup guides send machines to different places for tokens. I haven't tried a real token yet.
  • Every failure gives the same 401, so a provider outage looks just like a fake token.

The forged token my suite never builds

My tests replace the signature checker with a fake that says yes or no on command, then check what my code does next, and none of them generates a real key. Nothing ever gave the library a real reason to say no.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward