JWT
A JWT, or JSON Web Token, is a compact format defined in RFC 7519 for carrying claims between parties, signed as a JWS or encrypted as a JWE. It is a token format, not a session system: the receiver must validate algorithm, issuer and audience, and a live token cannot be revoked without state the format lacks.
How it works
A JWT is three base64url segments separated by dots: a header, a payload of claims, and a signature or an encrypted structure. Anyone holding the token can read the payload, because base64url is an encoding and not a cipher. That is the first thing to be clear about with a client who thinks the token protects the data inside it. A signed JWT protects integrity, not confidentiality. Only the encrypted form, JWE, hides the contents.
The header names the algorithm. The payload carries registered claims such as issuer, subject, audience and expiry, plus whatever the application adds. The signature covers the first two segments, so any change to the claims invalidates it, provided the receiver checks.
Its appeal is that it moves state to the client. A service can accept a request, verify a signature with a key it already has, read the user identity and the scopes out of the payload, and answer without asking anything else. That is genuinely useful across service boundaries. The cost is that the token is valid because it says so and because the maths holds, not because a server looked it up. Everything that goes wrong with JWTs comes back to that trade.
RFC 7519 also allows an unsecured JWT, with the algorithm set to none and an empty signature. That is a legitimate part of the specification for cases where the transport is already trusted, and it is the reason “JWT” and “signed” are not synonyms.
What goes wrong
Nearly every JWT finding we write is a validation that the receiver did not perform, and the reason is that the token arrives looking authoritative.
The first is trusting the header. If the receiver reads the algorithm from the token and then uses it, the attacker chooses the algorithm. Set it to none and, on a library that honours that, the signature is not checked. Set it to HS256 on a service that expects RS256 and some libraries will verify the HMAC using the public RSA key as the shared secret, and that key is public by definition. Both of these are old, both are fixed in current libraries, and both keep appearing in services that pinned a library version years ago or wrote their own verification.
The second is a weak secret. An HS256 token is an offline cracking target: we take a valid token from our own session and run it against a wordlist locally, with no requests to the service at all. Secrets that were chosen by a person, or copied from a framework tutorial, fall. Once the secret is recovered we mint tokens for any user with any claims, and nothing on the server distinguishes them.
The third is the claim nobody checks. A token issued by a different tenant of the same identity provider, for a different audience, is perfectly valid and signed by a key the service trusts. Skipping the audience and issuer checks turns a valid token from somewhere else into a valid token here. In multi-tenant estates this is the finding that matters most and the one least likely to appear in a scanner report.
The fourth is not a vulnerability but an operational fact that becomes one: you cannot revoke a JWT. A user logs out, an account is disabled, a role is removed, and every token already issued stays valid until it expires. Where that expiry is measured in hours, an attacker who stole a token keeps access for hours after the account was locked.
JWT compared with a server-side session
Teams often adopt JWTs to replace session cookies without noticing that they are trading one property for another. This is the trade, stated plainly.
| Server-side session | JWT bearer token | |
|---|---|---|
| Where the state lives | On the server, in a store | In the token, at the client |
| How it is validated | Look it up | Verify a signature |
| Revocation | Immediate, delete the record | Not possible without adding a store |
| Scales across services | Needs a shared store | Verifies anywhere the key is known |
| What a leak costs | Valid until deleted | Valid until it expires |
| Contents visible to the holder | No | Yes, unless it is a JWE |
The honest conclusion is that a JWT is a good fit for short-lived, service-to-service authorisation where the receiver cannot reach a session store, and a poor fit for a browser session in a single application, where a server-side session gives you revocation for free. Teams that need both usually end up with short access tokens plus a refresh token that is stored and revocable, which is a session store wearing a different name.
Common mistakes
Putting secrets in the payload. It is readable by anyone holding the token, including the browser, the logs it gets written to and any proxy in the path.
Long expiry to avoid dealing with refresh. Every hour of validity is an hour of access after a compromise is discovered. If tokens have to be long-lived, you need a revocation list, and at that point you have server state anyway.
Accepting the key the token points at. Header parameters that reference a key by URL or embed one directly are, unless the receiver pins them to a known issuer, an invitation to sign a token with a key of the attacker’s choosing.
Using a JWT as a session cookie without cookie protections. If the token rides in a cookie, it needs the same cookie security attributes and CSRF handling as any session identifier. Being a JWT changes nothing about that.
Assuming the token proves authorisation. It proves what the issuer asserted at issue time. If a role was revoked five minutes ago, the token still says otherwise.
How to reduce it
Pin the accepted algorithm on the receiving side and reject anything else, rather than reading the algorithm from the token. Validate issuer, audience, expiry and not-before on every request, and make those checks part of a shared library so a new service cannot forget them. Use asymmetric signing for anything crossing a trust boundary, so a verifying service never holds a key that can mint tokens. If you use HS256, the secret is a cryptographic key: generate it randomly at full length and store it in a secrets manager, not in configuration.
Keep access tokens short, and put revocation where it belongs, in the refresh path or in a denylist keyed by token identifier. Follow RFC 8725, the JSON Web Token best current practices document, which exists precisely because these mistakes recurred often enough to need writing down.
For detection, log the issuer, audience and key identifier of rejected tokens. A run of rejections with an unexpected algorithm, or with a valid signature but the wrong audience, is somebody testing. Signature failures alone are usually clock skew.
Where this shows up in an audit
In an API report, JWT findings are written against the verifying service and the specific check it omitted, never as “insecure JWT implementation”. The evidence is the token we forged or replayed, the claim we altered, and the request that the service accepted afterwards. Where we recovered an HS256 secret we state that it was cracked offline from a token issued to our own test account, and we do not put the secret in the report body.
Severity follows what the accepted token reaches. A forged token for a peer user is one finding; a forged token whose claims grant an administrative scope, or that crosses a tenant boundary, is a different one. Revocation gaps are written as a separate finding, because the fix is architectural and the client needs to see the cost before choosing.
This is a standing item when we test the authentication layer of an API, alongside OAuth 2.0 flows and object-level access checks.
FAQ
Is a JWT encrypted? Only if it is a JWE. The common signed form, JWS, is base64url encoded, which anybody can decode. Treat everything in the payload as public, and put nothing there you would not hand to the client.
Is the alg=none attack still relevant? As a default it is long dead in maintained libraries. It still appears in hand-rolled verification, in pinned old dependencies and in services that decode the token to read claims before verifying it, which is more common than it sounds.
How do you revoke a JWT? You do not, not in the format itself. You either wait for expiry, keep the lifetime short enough that waiting is acceptable, or maintain server-side state such as a denylist or a revocable refresh token. Anyone claiming otherwise has added a store somewhere.
Should we store JWTs in localStorage? No. A value in localStorage is readable by any script on the origin, so one cross-site scripting flaw hands over the token. A cookie with HttpOnly, Secure and SameSite is the safer default, with CSRF handled explicitly.