How do JSON Web Tokens (JWT) work?

JSON Web Tokens are everywhere in modern web APIs: single sign-on, OAuth flows, OpenID Connect, serverless authentication — almost everything touches this compact token format. Behind the cryptic-looking string made of three base64url-encoded parts sits a remarkably simple, but security-critical, concept. This article covers structure, signatures, standard claims, and the most common mistakes.

Structure: Header.Payload.Signature

A JWT consists of three base64url-encoded parts separated by dots. The header is a small JSON object with type information and the signing algorithm, for example: { "alg": "HS256", "typ": "JWT" }. The payload (or claims set) is also JSON and contains the actual information about the subject — user ID, permissions, expiry. The signature cryptographically protects header and payload.

Important: the payload is only encoded, not encrypted. Anyone seeing the token can read its content. Confidential data does not belong in the payload. For encryption, use JWE (JSON Web Encryption); the JWTs commonly used in practice are JWS — signed, not encrypted.

Signing algorithms

The header carries an alg field. The most common variants:

  • HS256 (HMAC-SHA256): symmetric — a shared secret between issuer and verifier. Fast and simple, good when both sides are under your own control.
  • RS256 (RSA-SHA256): asymmetric — the issuer signs with a private key, any number of clients verify with the public key. Standard for OAuth/OpenID identity providers where many clients need to validate tokens.
  • ES256 (ECDSA on P-256 with SHA-256): elliptic curves with smaller keys and signatures at security comparable to RSA. Popular in mobile and IoT contexts.
  • EdDSA (Ed25519): modern Edwards-curve signature, fast, deterministic, free of the classical ECDSA implementation pitfalls. Increasingly preferred in newer stacks.

Standard claims

RFC 7519 defines a few registered claim names with interoperable meaning:

  • iss (issuer): who issued the token? Usually a URL or domain identifier.
  • sub (subject): who or what is the token about? Usually the user ID, occasionally an application.
  • aud (audience): which recipients is the token intended for? A verifier should check that it appears in this list.
  • exp (expiration): UNIX timestamp at which the token expires. Verifiers must enforce this.
  • iat (issued at): UNIX timestamp of issuance. Useful to flag very old or future-dated tokens as suspicious.
  • jti (JWT ID): unique ID of the token. Allows individual tokens to be put on a blocklist — important for revocation strategies.

Where JWTs are used

In OAuth 2.0, access tokens and refresh tokens are often JWTs. OpenID Connect uses JWTs for the ID token, which conveys identity information about the signed-in user to the client. APIs use JWTs as Bearer tokens in the Authorization header (Authorization: Bearer eyJhbGciOi...).

The typical lifecycle: login sends username/password to an auth server, which returns a short-lived access JWT (e.g. 15 minutes) and a longer-lived refresh token. Follow-up API requests carry the access JWT, the server verifies the signature and selected claims. When the access JWT expires, the client uses the refresh token to obtain a new one — until the session ends at the auth server.

Common pitfalls

  • The "alg: none" attack: libraries that blindly trust alg can be tricked with the value "none" into accepting unsigned tokens. Algorithms must be pinned on the server side, e.g. via an explicit allowlist — not taken from the token.
  • Missing exp checks: without active validation of the exp claim, a once-issued token remains valid forever. Every validation library offers options for expiry and clock-skew checking.
  • LocalStorage vs. HttpOnly cookies: a JWT in localStorage can be read via XSS. It's safer to keep the token in an HttpOnly, Secure, SameSite cookie — even though cross-origin setups become slightly more complex.
  • Revocation: unlike a classical session ID, a JWT can't simply be invalidated on the server. To revoke a token before expiry, you need a blocklist (jti-based) or short lifetimes plus refresh tokens.
  • Weak HS256 secrets: an 8-character "secret" can be brute-forced offline. HS256 keys should be randomly generated with at least 256 bits of entropy — otherwise prefer RS256.

My first JWT bug in production

In 2019 I'd built a microservice using JWTs for authentication. Algorithm HS256, secret in an env var, classic setup. Three months ran fine. Then a user reported: 'I can't log in any more'. After two hours of debugging we found it: the server's clock had drifted 4 minutes into the future — the client created a token with iat = now, the server checked nbf <= now + 0, and the token was rejected as 'not yet valid'.

The lesson: JWT validation needs a tolerance ('leeway') of 30–60 seconds on time claims. Every mainstream library offers it as a config parameter — almost nobody sets it. Default is usually 0, which in the real world regularly causes edge-case failures. Since then I check in every JWT codebase where the leeway is.

Second lesson: NTP synchronisation of your servers. Without proper time sync, you can shoot down any JWT implementation even with perfect code. systemd-timesyncd or chrony are standard on Linux, but have to actually be running. Often forgotten in container deployments because the container host holds the clock. In my 2019 bug the service ran on a Docker container whose host hadn't done NTP for 11 days — and the drift had compounded.

OAuth 2.0/2.1: how JWT fits into the bigger picture

Many developers confuse OAuth and JWT. OAuth 2.0 is an authorization framework defining how apps obtain permissions. JWT is a concrete token format. OAuth tokens are often encoded as JWTs (especially with OpenID Connect), but that's not mandatory. OAuth can also return opaque strings — and JWTs are also used outside OAuth flows (e.g. simple API auth without an external identity provider).

OAuth 2.1 (RFC status 2024, broad adoption from 2026) consolidates the recommendations of recent years and makes some things mandatory: PKCE is mandatory for all client types (previously only recommended for mobile/SPA). Implicit flow is deprecated — anyone still on implicit flow must move to authorization-code-with-PKCE. Refresh-token rotation is recommended, some IdPs (Auth0, Okta) already enforce it.

Practically for 2026: anyone setting up a new system with OAuth should start OAuth 2.1-compliant directly. Anyone with an existing system can migrate iteratively — PKCE first, then implicit-to-authorization-code, then enable refresh-token rotation. Most identity providers document a migration path.

Refresh tokens: the stepchild nobody quite understands

JWTs are supposed to be short-lived (15 minutes is typical). But no human wants to log in again every 15 minutes. The solution: refresh tokens. These are long-lived (often 30 days) tokens that are not used for API calls, but only to issue new access tokens (=JWTs). Refresh tokens are typically stored in an httpOnly cookie (no JS access), access tokens in memory.

Refresh token rotation (OAuth 2.1 recommended): on each refresh, a NEW refresh token is issued and the old one immediately invalidated. If a refresh token is stolen, the attacker can use it at most once — on the second attempt BOTH sessions (legitimate + malicious) get detected as attack, and the entire token family is invalidated. One of the elegant security patterns of the past few years.

Debugging workflow: what to do when a JWT doesn't work

A 5-point workflow that has helped me with every JWT bug in recent years:

  • Throw the token into a decoder. Our JWT decoder shows you header and payload immediately. You see which algorithm is used, which claims are inside, and when it expires.
  • Compare server times. date -u on the auth server, then on the resource server. Drift more than 30 seconds? NTP is broken and you fix that before looking elsewhere.
  • Check the Key ID (kid) in the header. With asymmetric algorithms (RS256, ES256), the kid header indicates which public key to use for verification. If the server doesn't know that key (e.g. after a key rotation), the token is rejected.
  • Algorithm mismatch. Client signs with HS256, server expects RS256 — token rejected. Sometimes it's a library auto-picking; sometimes a config expecting two different values. The header decode reveals it quickly.
  • Secret/key content. For HS256: is the same secret on sender and receiver? Trailing whitespace, encoding issues (Base64 vs Hex), env vars with quotes — all common pitfalls. For RS256: is the public key the one matching the used private key?

With this workflow I've solved every production-relevant JWT bug of the past 5 years in under 30 minutes. The most common error is point 2 (time drift), followed by point 5 (secret mismatch). Point 1 (decoder) should always come first, because it makes the previously opaque token contents explicit.

Frequently asked questions

Are JWTs encrypted?

The JWTs that are typically meant in practice (JWS) are signed but not encrypted. If you also need to hide the content, you can additionally wrap them as JWE tokens — but that's rarely necessary if no sensitive data lives in the payload.

What's the difference between access and refresh tokens?

Access tokens are short-lived (minutes) and travel with every API request. Refresh tokens are longer-lived (hours to weeks), kept safely by the client, and only used to obtain new access tokens. This limits the damage from a stolen access token.

Should I use JWT or sessions?

For classic, server-rendered applications, sessions (cookie-based, server holds the state) are often simpler and safer. For API-only backends, mobile apps, or distributed microservices, JWTs play to their strengths: stateless, verifiable by any instance, well interoperable with OAuth standards.

What's the difference between JWT and JWE?

JWT (JSON Web Token) is signed but not encrypted — anyone can read the content, but nobody can modify it without invalidating the signature. JWE (JSON Web Encryption) is encrypted — content is only readable by the recipient. JWEs are rarely needed in practice: usually you want authentication, not content confidentiality. From the header algorithm you can tell: HS256/RS256 = JWT, RSA-OAEP/A256GCM = JWE.

How long should my JWT secret be?

For HS256 at least 256 bits (32 bytes) of random data — corresponding to a 32-character Base64 sequence. Longer secrets bring marginally more security but cost nothing. openssl rand -base64 64 generates a long, secure secret candidate. Important: not guessable values like 'supersecret123' — those get found in dictionary attacks immediately.

Can I revoke a JWT before it expires?

One of JWT's most uncomfortable properties: once issued, a JWT remains valid until expiry — regardless of what happens. Logout, password change, admin block: the token is still usable until exp is reached. Workarounds: (1) short lifetimes (15 minutes), (2) server-side blacklist of revoked JTI values (=stateful, undermining the 'stateless' benefit), (3) require additional auth for critical actions. Most production setups combine (1) and (2) selectively for high-risk endpoints.

Disclaimer: This article is a technical introduction and does not replace a specific security review of your application. For production systems, rely on established, actively maintained JWT libraries and follow the security recommendations of RFCs 7519, 8725, and 9068.

Comments