Imagine testing an invoice API. Alice signs in, opens her invoice, and gets the right response. The happy path works. But would the API also accept a token somebody edited themselves?

Faav’s write-up shows why that question matters. Read his post for the discovery. Here, I want to follow the mistake into our own code: how untrusted text becomes an identity, how to protect that boundary, and how to check that it holds.

How a token becomes a false identity

A JSON Web Token, or JWT, carries named values called claims. These can describe who the caller is, who issued the token and which API should accept it. A typical signed JWT has three parts: header.payload.signature.

The header and payload are encoded, not hidden. Anyone holding the token can read them or create different text. The signature is what allows the receiving API to detect a change and verify the token against a trusted signing key.

Original:
eyJ… . eyJzdWIiOiJhbGljZSJ9 . original-signature…
Decoded payload: {"sub":"alice"}

Edited:
eyJ… . eyJzdWIiOiJhZG1pbiJ9 . original-signature…
Decoded payload: {"sub":"admin"}

Illustrative, incomplete tokens: spaces and ellipses are for readability. The subject claim (sub) identifies the caller; “admin” is only an example value, not a standard grant of admin rights. The payload changed, so the original signature no longer verifies. A correctly configured API rejects the edited token.

That gives an attacker two straightforward things to try: change a claim while keeping the original signature, or supply an unsigned token with alg: none. In an API that requires signed access tokens, both must fail. Checking that the claimed username or application ID looks right cannot make either token trustworthy.

In .NET, ReadJwtToken only parses the token. It does not validate it. If the application takes those parsed claims and immediately selects a user or grants access, it has crossed the boundary without proof. The API documentation makes that distinction explicit.

The important line in a review is the point where the application starts treating supplied values as evidence about a caller. Giving them a type such as ClaimsPrincipal does not establish their authenticity.

Put validation in front of protected work

For Alice’s request, the API has three decisions to make. This first part concentrates on the first one; the other two still matter for a secure application.

Three decisions before accessing dataThe API validates the token, resolves an application account, and checks permission and invoice ownership before granting access.FROM A TOKEN TO AN INVOICE1. Validate the tokenCan we trust this evidence?Trusted issuer; valid signature.For this API, within its lifetime.2. Resolve the accountWho does it identify here?A stable application identity.The account really is Alice.3. Authorize this requestMay Alice read this invoice?Required permission + ownership.Alice may access this invoice.Continue only when all three decisions succeed.
One request, three different questions. A valid token alone does not grant access to an invoice.

Use the framework’s supported authentication handler with the correct configuration. For an API accepting signed JWT access tokens, it must verify:

  • Signature: the token verifies with a trusted key and an allowed algorithm.
  • Issuer: it came from an authority this API trusts.
  • Audience: it was issued for this API.
  • Lifetime: it has not expired and is usable now.

Then make sure the protected route actually uses that handler. Installing a package or registering authentication does not prove that every endpoint goes through it. Inspect custom handlers, validation overrides and development bypasses, especially code that continues after validation fails. Microsoft’s JWT bearer guidance covers the validation and route protection.

There is a responsibility boundary here too. MSAL obtains tokens for a client calling an API. The receiving API must validate them; using MSAL in the client does not provide that server-side protection.

Prove that a changed token stops

A successful request proves that one permitted case works. A negative test checks that a forbidden case fails. Keep both: an API that rejects every request is not the result we want either.

A request must pass before protected work beginsA client sends a token to the API; failed validation or permission checks return a rejection, while successful checks allow an invoice read and response.ALTClient appYour APIInvoice storeRequest + bearer tokenValidate the token.Resolve the account.Check access.[a check fails]Reject: 401 or 403No protected invoice read.[all checks pass]Read allowed invoiceInvoice200 + invoiceRead top to bottom. Solid: request. Dashed: reply.
The API rejects failed checks before reading the protected invoice. Account or policy lookups may still be needed to make those decisions.

Start in a test environment you control, with disposable credentials and synthetic data:

  1. Send a valid test access token to a protected endpoint and confirm the expected result.
  2. Decode its payload locally, change one claim, and encode it again. Keep the original header and signature unchanged.
  3. Send that changed token to the same endpoint. Expect rejection, normally HTTP 401, before the protected handler performs its work.

Also check a request with no token and one with an unsigned token. Run these through the real authentication middleware; an always-successful test authentication handler would hide the very behavior you are trying to check. Keep tokens out of logs, source control and shared decoding tools.

A subtle limit matters: editing any signed claim breaks the signature. This test therefore does not separately prove that audience or expiry checks work. Those need otherwise valid, correctly signed test tokens with the relevant property changed. A signature check is an essential starting point, not a complete security audit.

One prompt to start your review

Merill Fernando’s checklist, which I saved in this gist, inspired the prompt below. Copy it into a coding assistant with access to the repository you want to review. It asks for evidence and proposed tests before changes.

JWT validation audit prompt
Review this repository for authentication paths that trust a token or
identity before it has been validated. Do not change code, send network
requests to running services, or print credentials or full tokens.

1. Find the entry points that accept an identity: bearer tokens, custom
   authentication handlers, gateway/platform identity headers, and
   development or test bypasses. Include callbacks or webhooks where
   relevant, but identify their actual authentication protocol.

2. Trace each path from incoming data to validation and the first protected
   operation. For signed JWT access tokens, verify trusted signature/key
   and algorithm handling, issuer, audience and lifetime. Check that
   unsigned or tampered tokens fail and that the route uses the validator.
   Review custom overrides and behavior when validation fails.

3. Distinguish decoding from validation and client-side token acquisition
   from API protection. For trusted headers, identify what prevents
   spoofing or bypassing the authenticating gateway. Flag deployment
   assumptions that cannot be verified from this repository.

4. Report confirmed findings, suspicious patterns and unknowns separately.
   Include file:line evidence, affected entry points, likely impact and a
   suggested fix order. A parser call or package name alone is not proof.
   Record account-mapping and authorization concerns as follow-up items;
   do not imply that signature validation alone makes the API secure.

5. Propose tests using the real validation path in an isolated environment:
   a valid control request, no token, a tampered payload, an unsigned token,
   an untrusted signing key, and correctly signed tokens with an invalid
   issuer, audience or lifetime. State expected rejection and how to verify
   that protected work did not execute. Changing a signed payload only
   tests integrity; it does not isolate the other claim checks.

Use official documentation for the framework and installed versions.
Finish with a short entry-point table, the most urgent finding or unknown,
and the first test to run. Do not claim checks or tests were run unless
there is evidence that they were.