JWT

A self-contained token that carries its own claims - and proof they haven't been tampered with

Why JWT?

The standard case: a user logs in. You need every subsequent request to carry proof of who they are. The traditional approach is a session: store the user's state on the server, give the client a session ID, look it up in a database on every request.

JWT takes a different approach. Instead of storing state on the server and handing out a reference to it, you encode the state directly into a token and give that to the client. The token is signed - so the server can verify it hasn't been tampered with - but there is nothing to look up. The token carries everything the server needs to trust the request: who the user is, what they're allowed to do, and when the token expires.

This is useful in distributed systems: any server that knows the signing secret (or has the public key) can verify the token independently, without talking to a central session store. It is also the token format used by OAuth 2.0 and OIDC access tokens in most implementations.

What a JWT looks like

A JWT is three base64url-encoded strings joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxNDAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The three parts are:

  • Header - metadata about the token: the type (JWT) and the signing algorithm being used.
  • Payload - the claims: the data the token is asserting. Who the user is, what they can do, when the token expires.
  • Signature - proof that the header and payload have not been modified since the token was issued.

Decoded, the header and payload are plain JSON:

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

// Payload
{
  "sub": "user_123",    // subject - who this token is about
  "role": "admin",
  "exp": 1714000000,    // expiry - unix timestamp
  "iat": 1713996400     // issued at
}

Base64url is not encryption. Anyone who gets hold of a JWT can decode the header and payload and read everything in them - there are online tools that do it instantly. Do not put passwords, card numbers, or anything sensitive in the payload. JWTs prove authenticity, they do not provide secrecy.

How the signature works

The signature is computed over the encoded header and payload:

signature = HMAC-SHA256(
  secret,
  base64url(header) + "." + base64url(payload)
)

When the server receives a token it recomputes the signature using the same secret and compares it to the one in the token. If they match, the header and payload are exactly as the server originally issued them - nobody has changed the role field, the expiry, or anything else. If someone tampers with the payload, the signature no longer matches and the token is rejected.

This is the same HMAC mechanism used for webhook signing. The JWT spec wraps it in a standard format.

HS256 vs RS256 - symmetric vs asymmetric signing

The alg field in the header declares the signing algorithm. The two most common are:

  • HS256 (HMAC + SHA-256) - a single shared secret signs and verifies the token. Both the issuer and anyone verifying the token must know the secret. Simple to set up; the right choice when one service issues and verifies tokens itself.
  • RS256 (RSA + SHA-256) - a private/public key pair. The issuer signs with the private key; anyone verifying needs only the public key. The right choice when multiple services need to verify tokens, or when you want to publish your verification key publicly without exposing signing capability. Auth0, Google, and most identity providers use RS256.

The practical difference: with HS256, any service that can verify a token can also forge one - because verifying and signing use the same secret. With RS256, knowing the public key lets you verify tokens but not create them. In a multi-service architecture where only one service (your auth server) should be able to issue tokens, RS256 is the safer choice.

You will also encounter ES256 (ECDSA + SHA-256) - the same idea as RS256 but using elliptic curve cryptography. Smaller keys, faster operations. Increasingly preferred over RS256 for new systems.

Standard claims

The JWT spec defines a set of registered claim names. You are not required to use them but they have standard meanings that libraries and other services understand:

  • sub (subject) - who the token is about, typically a user ID.
  • iss (issuer) - who issued the token. Your auth server's URL.
  • aud (audience) - who the token is intended for. The API or service that should accept it.
  • exp (expiry) - unix timestamp after which the token must be rejected.
  • iat (issued at) - when the token was created.
  • jti (JWT ID) - a unique identifier for this specific token. Used for revocation.

You can add any custom claims you need alongside these. The payload is just a JSON object.

The attacks to know about

The "alg: none" attack. The JWT spec originally allowed an algorithm value of none, meaning no signature. Some early library implementations accepted this at face value: if a token arrived with "alg": "none" and no signature, they would accept it as valid. An attacker could take any token, change the payload to whatever they wanted, set alg to none, strip the signature, and the server would trust it. This was a real vulnerability in production systems. Any library worth using today rejects alg: none by default, but it is worth knowing why you should never accept whatever algorithm the token claims - your server should enforce the algorithm it expects.

The RS256-to-HS256 confusion attack.Some older libraries had a related flaw. If a server expected RS256 tokens (signed with a private key, verified with a public key), an attacker could construct an HS256 token signed with the server's public key. The server's public key is often published openly. If the library passed the public key into whichever algorithm the token claimed rather than whichever algorithm the server configured, it would attempt to verify the HS256 token using the public key as the HMAC secret - and succeed. Again: your server decides the algorithm, not the token.

Not verifying the signature at all. More common than it sounds. A JWT is decodable without verification - just split on dots and base64-decode. Developers under time pressure sometimes decode the payload to read the user ID and forget to call the verify step. The payload is completely untrustworthy without signature verification.

The revocation problem

The stateless nature of JWTs is also their main limitation. A session stored in a database can be deleted - the user is immediately logged out and the session ID is worthless. A JWT cannot be unissued. It remains valid until it expires.

If a user logs out, changes their password, or you suspect a token is compromised, the token keeps working until exp. The standard mitigations:

  • Short expiry. Issue access tokens with a short lifetime (15 minutes is common) and pair them with a longer-lived refresh token. Compromise is limited to the expiry window.
  • Revocation list. Store revoked JWT IDs (jti) in a fast cache (Redis). Check every token against the list. This reintroduces state but only for the revoked minority.
  • Token rotation. Each use of a refresh token issues a new one and invalidates the old. Detecting a reuse of an old refresh token is a signal of compromise.

If you need instant revocation for every token, a session store is simpler. JWTs are the right choice when the statelessness genuinely helps - distributed systems, short-lived tokens, issuing tokens for third parties to verify.

Where to store a JWT in the browser

The two options are localStorage and an HttpOnly cookie. The tradeoff:

  • localStorage - accessible to JavaScript, which means any XSS vulnerability on your site can steal the token. An attacker who can run script can read the token and exfiltrate it. The token is then usable from anywhere, not just your site.
  • HttpOnly cookie - inaccessible to JavaScript. XSS cannot read it. The browser sends it automatically with every request to your domain. The downside: you need CSRF protection, because the automatic sending is exactly what CSRF exploits.

The general recommendation is HttpOnly cookie with SameSite=Strict or SameSite=Lax, which largely mitigates CSRF without needing a separate token. Avoiding XSS in the first place is important either way - but with a cookie, XSS can do damage on your site but cannot exfiltrate the token for use elsewhere.

Storing a JWT in localStorage is widely done and widely criticised. The practical risk depends on your XSS exposure - but a stolen JWT is portable in a way a stolen session cookie is not, because it carries its own proof of validity.

Read nextCSRFIf you store your JWT in a cookie, the browser sends it automatically - which is exactly what CSRF exploits. Understanding CSRF is the other half of the cookie storage decision.