Skip to content
Backend Infrastructure8 min

Decoded is not verified: the JWT that trusts whatever the client wrote

Splitting a token on dots and JSON-parsing the middle segment reads the claims without checking the signature. At that point the token is just a client-supplied object — and “role: admin” is a field the client can set.

Failure modeAn identity or authorization decision is made from JWT claims that were decoded but never cryptographically verified, or from a mutable profile record the caller can influence — so a forged token or an altered role grants access the system believes it authenticated.

Commercial risk

This is not a data-quality bug; it is an authentication bypass. An attacker who can craft a token or edit a role field becomes whoever they choose — and in a platform holding health, financial, or children's data, that is the difference between a private record and a headline.

The failure is invisible in normal use because legitimate clients send legitimate tokens, so every test and every real login works. The hole only opens when someone deliberately sends a token you never verified — which is precisely the person you built authentication to stop.

A JSON Web Token has three parts: header, payload, and signature. The entire security of the scheme lives in the signature — it is the only thing that proves the payload came from a party holding your secret and wasn't altered in transit. Decoding the payload is trivial and proves nothing; it's base64 that anyone can read and anyone can rewrite. The distinction between decoding a token and verifying it is the distinction between reading a claim and trusting it.

The failure mode is code that reads the claim and trusts it. It shows up as hand-rolled token parsing that skips verification, and as a subtler cousin: authenticating the token correctly but then reading authorization from a mutable store the caller controls. Both hand the client a say in who they are.

1. The parse that skips the only step that mattered

The tell is a token being split on `.` and its middle segment base64-decoded and JSON-parsed to pull out a claim — a user ID, a tenant, a role. There is no call into a verification routine, no secret, no public key, no signature check. Frequently it's wrapped in a comment like "fall through if the secret isn't configured," which means: if verification can't run, use the claims anyway. That is a door with a lock that opens itself when the key is missing.

Once you accept an unverified payload, every claim in it is attacker-controlled. An attacker copies a real token's structure, changes `sub` to another user's ID or `role` to `admin`, re-encodes it, and sends it. Your code decodes it, reads the claim, and acts on it. The signature that would have rejected the tampered token was never consulted. You didn't authenticate anyone; you read a form the client filled out.

// Decoding. NOT verifying. Every field here is attacker-controlled.
const payloadB64 = token.split(".")[1];
const payload = JSON.parse(Buffer.from(payloadB64, "base64").toString());
if (payload.tenantId) return { tenantId: payload.tenantId }; // trusts a forgeable field

// Verifying: the signature is checked, or it throws.
import { jwtVerify } from "jose";
const { payload } = await jwtVerify(token, publicKey, {
  issuer: EXPECTED_ISSUER,
  audience: EXPECTED_AUDIENCE, // audience/issuer pinning closes token-replay across apps
});
return { tenantId: payload.tenantId as string }; // now it means something

2. Authenticated the token, authorized from a mutable field

The second variant verifies the token correctly and then makes the authorization decision from the wrong place. The token proves who the user is; the code then reads what the user is allowed to do from a profile document in a datastore — and if that document is writable by the user, or by anything the user can reach, the role is no longer a fact about them, it's a preference they can set.

Concretely: a login verifies a Firebase ID token, then fetches the user's role from a profile record and grants web-admin access based on it. If that profile store lacks tight write rules, elevating yourself is a matter of setting your own `role` field to `super_admin`. The authentication was sound; the authorization trusted a value the subject controls. Trust for authorization has to come from a source the subject cannot write — signed custom claims minted server-side, or a roles table only your backend can mutate.

3. Verify signatures, pin issuer and audience, source roles from server-writable state

Never hand-roll token parsing. Use the platform's verification library, give it the secret or public key, and let it check the signature, expiry, issuer, and audience — and let it throw. A verification failure is a rejected request, never a fall-through to trusting the claims. If the key isn't configured, the correct behaviour is to refuse to start, not to authenticate everyone.

Pin the issuer and audience explicitly. A signature check alone still accepts a validly-signed token minted for a different application in the same identity ecosystem; pinning `iss` and `aud` ensures the token was issued by who you expect, for you. And derive authorization from state only your server can write: signed custom claims for coarse roles, a backend-owned permissions table for anything finer. Re-check on sensitive actions rather than trusting a role snapshot taken at login, so a revoked privilege actually takes effect before the next dangerous operation.

Finally, lock down whatever profile store feeds identity. If a datastore document can influence who a user is or what they can do, its write rules are part of your auth system and must be as strict as everything else in it — the caller gets to write their preferences, never their permissions.

The whole point of a signed token is the signature. Skip it — split on dots and read the payload — and you haven't authenticated anyone; you've accepted a client-supplied object with a `role` field the client can set to anything. Verify the token and then authorize from a store the user can write, and you've closed the front door while leaving the window open.

Verify signatures with the platform library, pin issuer and audience, fail closed when you can't verify, and source every authorization decision from state only your backend controls. Identity is the one input you can never take on the client's word — the moment you do, you no longer have authentication, only the appearance of it.

Need this level of structural integrity engineered for your system?

We audit and re-architect systems where a single one of these failure modes costs real money. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint