A JSON Web Token is a small, signed bundle of claims: who the user is, who issued the token, who it is for, and when it stops being valid. The signature is what makes those claims trustworthy. Get the signature check right and a JWT is a perfectly good way to carry identity between services. Get it slightly wrong and anyone can mint a token that says they are an administrator.

If you are new to the format, JWTs explained walks through the header, payload and signature first. This post covers the mistakes that turn a signed token into a forgeable one, and the checks that close each of them.

Why JWT bugs live on the verifying side#

Every JWT carries instructions about how to check it. The header names the algorithm (alg), may name which key to use (kid), and may even point at a URL where keys can be fetched. The payload says when the token expires and who it was meant for. All of that is written by whoever produced the token, and an attacker can produce a token too.

A verifier that obeys the token's own description of how to verify it has handed the decision to the attacker. The IETF collected the lessons in RFC 8725, the JWT Best Current Practices published in February 2020, and most of its advice comes back to one idea: the server decides which algorithms, keys, issuers and audiences it accepts, before it looks at the token.

Decoding is not verifying#

The simplest mistake is not checking the signature at all. The header and payload are just Base64url-encoded JSON, so reading them takes one line of code and no key. Most JWT libraries offer a decode function for exactly that purpose, alongside a separate verify function that checks the signature and the time claims.

The trouble starts when decode "just works" in testing and ships to production. The application then reads sub and role from a token it never authenticated. An attacker can decode the payload of any valid token, change "role": "user" to "role": "admin", re-encode it, and leave the old signature attached. Nothing notices. You can see how little protection the encoding offers by pasting a payload segment into a Base64 decoder; Base64 explained covers why encoding is not encryption.

The rule is short: decode is for display and debugging. Any code path that makes an access decision must call verify, and must treat a verification failure as a rejection rather than a warning to log.

Accepting alg: none#

The JWT specifications define an algorithm called none. A token with "alg": "none" is an Unsecured JWS: it has an empty signature and makes no integrity claim at all. It exists for cases where the token is already protected some other way, such as being passed inside a channel that is itself authenticated.

In 2015, security researcher Tim McLean showed that several popular JWT libraries treated a none token as one with a verified signature. An attacker could write any claims they liked, set the algorithm to none, drop the signature, and the library would report the token as valid.

The standard is explicit on this. RFC 7518 says implementations must not accept Unsecured JWSs by default, and that an application may only accept one when it has said so for that specific object, never as a global setting. Maintained libraries follow that rule, but old versions and hand-written verifiers still turn up, so make sure yours rejects none unconditionally.

Letting the token choose its own algorithm#

The same 2015 research described a subtler attack, now usually called algorithm confusion. JWTs can be signed two ways. HS256 is an HMAC: one shared secret both signs and verifies. RS256 is RSA: a private key signs, and a public key verifies. The public key is, by design, public.

Now picture a server that expects RS256 tokens and calls a generic verify(token, key) function, passing in its RSA public key. If the library picks the algorithm from the token's header, an attacker can send a token marked HS256. The library then computes an HMAC using the "key" it was given, which is the RSA public key, treated as a string of bytes. The attacker knows that public key too, so they can compute the same HMAC over any payload they want. The forged token verifies.

The fix is to pin the algorithm on the server. RFC 8725 requires libraries to let the caller specify the set of accepted algorithms, and the caller should always use that option: if you issue RS256 tokens, tell the verifier to accept RS256 and nothing else. A related good habit is to bind each key to one algorithm, so an RSA key can never be fed into an HMAC check in the first place.

Signing with a weak secret#

An HS256 token is only as strong as its secret, and that secret has a property people tend to forget: it can be attacked offline. Anyone who holds a single valid token has everything needed to test guesses. They take the header and payload, compute the HMAC with a candidate secret, and compare it to the signature. No requests go to your server, so there is no rate limit, no lockout and no log entry. The popular password-cracking tool hashcat supports JWTs as a built-in mode for exactly this job.

Secrets like secret, changeme or a value copied from a tutorial fall quickly to a wordlist. Once the secret is found, every token the attacker signs is genuine as far as your server can tell.

Two rules from the specifications cover this. RFC 7518 requires an HMAC key at least as long as the hash output, which means at least 256 bits for HS256. RFC 8725 adds that a human-memorable password must never be used directly as the key. Generate the secret from a cryptographically secure random source, keep it out of source code, and rotate it if it leaks. When many services verify tokens, an asymmetric algorithm such as RS256 or ES256 means verifiers hold only a public key and cannot mint tokens.

Tokens that never expire#

The exp claim sets the moment after which a token must be rejected. RFC 7519 makes it optional, and that is where the trouble begins. Libraries generally reject a token whose exp has passed, but a token with no exp claim at all often passes the time check, because there is nothing to compare. A token minted without one stays valid for as long as the signing key does, which may be years.

That matters because a JWT is hard to take back. Logging out or disabling an account does nothing to a token already handed out, so expiry is the main limit on how long a stolen token stays useful.

  • Always set exp when issuing, and configure the verifier to require it rather than merely check it when present.
  • Keep access tokens short-lived, minutes rather than days, and use a refresh token that the server can revoke to issue new ones.
  • Keep clock leeway small. RFC 7519 allows a little tolerance for clock skew, usually no more than a few minutes. A leeway of hours quietly extends every token.
  • Watch the units. JWT times are seconds since 1970, not milliseconds. Writing a millisecond timestamp into exp produces a date tens of thousands of years in the future, a token that effectively never expires.

Skipping the audience and issuer checks#

A correctly signed, unexpired token can still be the wrong token. When several services trust the same identity provider, a token issued for an internal reporting tool may also be accepted by the billing API. RFC 8725 calls this cross-JWT confusion: a token issued for one purpose being used for another.

The aud (audience) claim exists to stop it. Each service should check that its own identifier appears in aud and reject the token if it does not. RFC 7519 already requires rejection when aud is present and does not name the recipient; RFC 8725 goes further and says a relying party must reject the token if the audience is missing too. Check iss the same way, against the exact issuer you expect, and confirm that the key used to verify actually belongs to that issuer.

Trusting key hints in the header#

When an issuer rotates keys, the header's kid (key ID) tells the verifier which one signed this token. That is useful, but kid is attacker-supplied text. Verifiers that pass it straight into a database query, a filesystem path or a directory lookup have created an injection point; RFC 8725 specifically warns about SQL and LDAP injection through kid. Treat it as a lookup key into a fixed set of keys you already hold, never as a value to build a query or path from.

The jku and x5u headers are riskier still. They carry a URL where the verifier can fetch the signing key or certificate. A verifier that follows them blindly will download a key chosen by the attacker and use it to check the attacker's own signature, which of course succeeds. Fetching an arbitrary URL from inside your network is also a server-side request forgery risk. Most services are better off configuring their key sources directly and ignoring these headers; if you must use them, allow only exact, trusted URLs.

A verification checklist#

Put together, correct verification means the server answers every question itself before trusting the token:

  • Use a maintained JWT library, and call its verify function, never decode, on any path that grants access.
  • Pass an explicit list of accepted algorithms, and reject none.
  • Use a random HMAC secret of at least 256 bits, or an asymmetric key pair when many services verify.
  • Require exp, keep lifetimes short, and keep leeway to a few minutes at most.
  • Check aud and iss against exact expected values.
  • Resolve kid against keys you already hold, and do not follow jku or x5u to untrusted URLs.
  • Keep secrets out of the payload, since anyone holding the token can read it.

When you need to look at a real token, paste it into our JWT decoder. It decodes the header and payload entirely in your browser, flags a token whose algorithm is none as unsigned, shows the kid if there is one, and turns exp, iat and nbf into readable dates with a note when exp has passed or is missing. It does not verify signatures, since that needs your key, and a signing secret should never be pasted into a website. Reading a token tells you what it claims; verifying it is the server's job, done with settings the server chose.

Try it

Inspect a JSON Web Token

Decode a JWT's header and payload, check its algorithm and expiry, and read every claim. Decoding runs in your browser and the token is never sent to a server.

Open the JWT Decoder →