Security

JWT: anatomy, signature and security

The biggest misunderstanding about JSON Web Tokens fits in one sentence: a JWT hides nothing. It is signed, not encrypted. The three parts you see separated by dots are just text encoded in base64url, anyone holding the token reads the contents in seconds, with no key at all. The signature does not protect the secret; it proves nobody tampered with what is written. Grasping that distinction is what separates people who use JWT securely from people who put the user’s password in the payload thinking it is protected. This guide breaks down the three parts, walks the registered claims from [RFC 7519](https://www.rfc-editor.org/rfc/rfc7519), reconstructs the classic attacks and ends with the rule almost nobody applies: when NOT to use JWT. Paste a real token into the [JWT Decoder](tool:jwt-decoder) as you read, it decodes and verifies HMAC locally, sending nothing to a server.

J-Kit12 min readAdvanced
  • JWT
  • Authentication
  • Security
  • Cryptography

Key takeaways

  • A JWT has three base64url parts: header, payload and signature. The first two are only encoded, not encrypted, readable by anyone.
  • The signature guarantees integrity and authorship, not secrecy. Never put sensitive data in the payload of a signed JWT.
  • The classic attacks (alg:none, RS256→HS256 confusion, kid/jku injection) exploit libraries that trust the header to pick the algorithm or the key.
  • JWT is stateless: a token is valid until exp and cannot be invalidated without reintroducing server state. For web-app sessions, an opaque cookie is usually better.

The three parts of a JWT

A JWT is a string with three blocks separated by dots: `header.payload.signature`. Each block is a JSON object encoded in base64url, the RFC 4648 §5 variant that swaps `+` for `-`, `/` for `_` and drops the `=` padding, precisely so it fits in URLs and HTTP headers without escaping. The header states which algorithm signs the token (`alg`) and the type (`typ`). The payload carries the claims, the assertions about the user or session. The signature is what binds those first two to a secret key.

// Um JWT real (exemplo didático público, secret = "your-256-bit-secret")
// A real JWT (public didactic example, secret = "your-256-bit-secret")

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9          ← header
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ   ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   ← signature

// Header decodificado / decoded:
{ "alg": "HS256", "typ": "JWT" }

// Payload decodificado / decoded:
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
The three parts and the JSON each one hides. Nothing here is secret: base64url is reversible by anyone.
3dot-separated blocks
base64urlencoding of each block (RFC 4648 §5)
0keys needed to READ the payload
Paste the token above: the decoder splits the three parts and shows the JSON, all in the browser, no network.Open the tool full page

Signed, not encrypted

The format you see day to day is a JWS, JSON Web Signature, defined by RFC 7515. "Signed" means the issuer computes an authentication code over `header.payload` using a key, and the verifier recomputes it to confirm nothing changed. If an attacker alters a single character of the payload, the signature stops matching. But note: a signature is not a cipher. The content is still plaintext, merely encoded. There is an encrypted JWT, the JWE, from RFC 7516, but it is rare, has five parts instead of three and is another story. When someone says "JWT", they almost always mean a readable JWS.

signature = base64url( HMACSHA256( base64url(header) + "." + base64url(payload), secret ) )
HMACSHA256
keyed message authentication code with SHA-256
secret
symmetric key known only to the issuer and the verifier
base64url
URL-safe encoding without padding (RFC 4648 §5)
The HS256 signature: an HMAC-SHA256 over the two already-encoded first parts, keyed by a secret. Changing any byte of the header or payload changes the signature entirely.
JWS (RFC 7515)
Signed token. Proves integrity and authorship; the content is readable. The everyday JWT.
JWE (RFC 7516)
Encrypted token. Here the content really is unreadable without the key. Five parts, rarely used.
JWA (RFC 7518)
The catalog of algorithms (HS256, RS256, ES256, none…) the header may declare in `alg`.

The registered claims

The payload is a free JSON object, but RFC 7519 (§4.1) reserves seven claim names with standardized meaning. None of them is mandatory, the RFC itself says "none of the claims defined below are intended to be mandatory to use or implement in all cases". That surprises people who assume a JWT always has an expiry. It does not: a token with no `exp` is valid forever, until the key rotates. It is on you to require the claims your application needs and to validate every one of them.

The seven registered claims of RFC 7519 §4.1. All optional in the standard, but you should require and validate the ones that matter.
ClaimNameMeaningMandatory?
issIssuerWho issued the token.Optional
subSubjectWho the token is about (the user).Optional
audAudienceWhich recipients the token is valid for.Optional
expExpiration TimeInstant on or after which the token MUST NOT be accepted.Optional
nbfNot BeforeInstant before which the token MUST NOT be processed.Optional
iatIssued AtWhen the token was issued.Optional
jtiJWT IDUnique identifier for the token (useful for a denylist).Optional

The three time claims, `exp`, `nbf`, `iat`, are seconds since 1 January 1970 (the Unix epoch). A correct verifier rejects the token if the current time is greater than or equal to `exp`, or less than `nbf`, allowing a small clock skew of a few seconds between servers. Validating `aud` and `iss` matters just as much: a token issued for service A must not be accepted by service B merely because the signature is valid. A valid signature answers "this issuer signed it"; it does not answer "this token was meant for me".

The classic attacks

Almost every JWT attack exploits the same root flaw: the library trusts the token’s header, which the attacker controls, to decide how to verify. RFC 8725 (JWT Best Current Practices, BCP 225) exists precisely to catalog these traps. Three of them show up again and again in real audits.

alg: none, the signature that isn’t there

Mechanism: JWA (RFC 7518) defines a literal `"none"` algorithm meaning "unsecured token". A naive library reads `{"alg":"none"}` in the header and simply skips verification, accepting any payload with an empty signature field. The attacker forges `{"alg":"none"}`, writes `{"sub":"admin"}` in the payload, leaves the third part blank and walks in.

Mitigation: always reject `none`. RFC 8725 requires the library to let the caller declare the accepted set of algorithms and to use no other. Never let the token’s header pick the algorithm on its own.

RS256 → HS256 confusion, the public key turned into a secret

Mechanism: RS256 is asymmetric, the server signs with the RSA private key and anyone verifies with the public key (which is, by definition, public). HS256 is symmetric, the SAME key both signs and verifies via HMAC. The attack: the verification code receives the public key and lets the token’s `alg` pick the algorithm. The attacker swaps `RS256` for `HS256` in the header, takes the known public key and uses it as the HMAC secret to re-sign the tampered token. The server, trusting `alg`, runs HMAC-SHA256 with the public key as the secret, and it matches. Forged token, accepted.

Mitigation: pin the algorithm on the verifier side. The key must be bound to the expected algorithm: an RSA key verifies RS256 and only RS256. Do not let the token say whether it is HMAC or RSA. Modern tooling demands an explicit allowlist precisely to shut this door.

kid / jku / x5u injection, pointing at the wrong key

Mechanism: the header can carry `kid` (a key id), `jku` (the URL of a key set) or `x5u` (the URL of a certificate). If the server uses `kid` to build a SQL query or a file path without validating it, that opens SQL injection or path traversal. If it fetches the key from the `jku`/`x5u` URL with no restriction, the attacker points it at a server they control, supplies their own key and gets the token accepted, plus exposes the server to SSRF.

Mitigation: RFC 8725 recommends treating `kid` as untrusted data (never concatenate it into a query or path) and matching `jku`/`x5u` against an allowlist of trusted locations. Better still: never fetch the key from a token value; use a key set configured on the server.

Notice the common thread: in all three, the server handed the attacker a decision that was the server’s to make. The single, cheap antidote is a server-side allowlist, of algorithm and of key, combined with validating the time claims, `aud` and `iss`. The JWT Decoder verifies an HMAC signature when you supply the secret, which helps you reproduce and understand the RS256/HS256 confusion in a safe place, without sending your token anywhere.

Revocation: the Achilles’ heel

The appeal of JWT is being stateless: the server keeps no session at all, it just checks the signature and trusts what is written. That is also its biggest flaw. A signed token is valid until `exp`, and there is no server-side record to "switch off". If the user logs out, gets banned or changes their password, the token already in their hands keeps working until it expires. There is no button that invalidates an issued JWT, unless you reintroduce state.

Denylist (revocation list)

  • Stores revoked `jti` values and checks them on every request.
  • Immediate revocation, but reintroduces the stateful lookup JWT promised to avoid.

Short access token + refresh

  • The access token lives minutes; the longer refresh token stays server-side and can be revoked.
  • The abuse window shrinks to the access token’s lifetime, with no lookup on every request.

The two common ways out are shown above, and both cost something. The denylist hands the server back the state JWT wanted to drop; short tokens with refresh reduce the problem rather than remove it, at the price of more flow complexity. There is no free instant revocation in a genuinely stateless system, it is an engineering choice, not a config detail. If the HMAC secret leaks, the dilemma vanishes: you rotate the key and every token falls at once. Generate a long, random HS256 secret with the hash generator and guard it the way you would a master password.

Secure validation and when NOT to use JWT

  • Validate `alg` against a server-side allowlist; reject `none` and any algorithm not on the list.
  • Bind the key to the expected algorithm: an RSA key verifies RS256, never HS256. Never let the token pick the key type.
  • Check `exp` and `nbf` (with a small clock skew) and verify `aud` and `iss` against the values your application expects.
  • Never trust `kid`, `jku` or `x5u` to fetch the key; use a key set configured on the server.
  • Use long, random secrets for HMAC; no dictionary words or short reused strings.
  • Never put sensitive data in the payload, it is only base64url, readable by anyone.

With the security lesson done, here is the most honest and least-asked question: do you actually need JWT? For a user’s session in a web app with your own backend, the answer is almost always no. An opaque session cookie, a random id pointing to a session record on the server, is simpler and safer: revocation is deleting the row, there is no algorithm-confusion surface, no risk of leaking data in the payload and the cookie is tiny. JWT shines elsewhere: authorization across services, when several backends must validate the token without sharing a session store, in identity federation and in short-lived signed assertions. If your case is "the user logs in and browses my site", prefer the opaque session.

Frequently asked questions

Is a JWT encrypted?
No, not in its usual form. The everyday JWT is a JWS (RFC 7515): signed, not encrypted. Header and payload are just base64url, readable by anyone without a key. There is an encrypted JWT, the JWE (RFC 7516), but it is rare and has five parts. That is why you must never put sensitive data in a common JWT’s payload.
What is the alg:none attack?
It is when the attacker sets `{"alg":"none"}` in the header, meaning "unsecured token", and a naive library skips verification and accepts any payload. The defense is to reject `none` and require the algorithm to be on a server-side allowlist, as RFC 8725 recommends.
Can I invalidate a JWT before it expires?
Not without reintroducing state. JWT is stateless: a token is valid until `exp` and there is no server record to switch off. The ways out are a denylist of `jti` checked on every request (stateful again) or short access tokens with revocable refresh tokens. Or, in an emergency, rotating the secret, which drops every token at once.
Which claims are mandatory in a JWT?
None. RFC 7519 §4.1 defines seven registered claims (iss, sub, aud, exp, nbf, iat, jti) and says none is mandatory to use. In practice, you should require and validate `exp` to bound the token’s life, and `aud`/`iss` to ensure the token was meant for your service and came from whom you expect.
JWT or session cookie: which should I use?
For the login session of a web app with your own backend, an opaque session cookie is usually better: instant revocation, no algorithm confusion and no risk of leaking data in the payload. JWT is worth it when several services must validate the same token without sharing a session store, in federation or in short-lived signed assertions.

A JWT is signed, not encrypted: the three parts are readable base64url, and the signature only guarantees nobody tampered with the content. Validate `alg` against an allowlist, bind the key to the algorithm, check `exp`/`nbf`/`aud`/`iss` and never let the header pick the key, that is how you close alg:none, RS256/HS256 confusion and kid injection. And remember that revoking a JWT requires state: for a web-app session with your own backend, the opaque cookie is usually the simpler, safer choice.

Sources & references

  1. RFC 7519, JSON Web Token (JWT)
  2. RFC 7515, JSON Web Signature (JWS)
  3. RFC 7516, JSON Web Encryption (JWE)
  4. RFC 7518, JSON Web Algorithms (JWA)
  5. RFC 8725, JSON Web Token Best Current Practices (BCP 225)
  6. RFC 4648 §5, Base 64 Encoding with URL and Filename Safe Alphabet
  7. OWASP, JSON Web Token Cheat Sheet