JWT Explained

A JWT is three Base64 segments and a signature. Understanding what the signature does — and does not — prevent is the whole game.

JSON Web Tokens are everywhere — API authentication, single sign-on, service-to-service calls — and they are widely misunderstood. The misunderstanding is predictable, because the thing that looks most important (the signature) protects something different from what most people assume, and the thing that looks like decoration (the Base64 encoding) is what actually determines what you are allowed to put in a token.

Anatomy of a JWT

A JWT is three Base64URL-encoded segments separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjM0NTY3ODkwIn0 . SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└──────────── 1. header ────────────┘   └────── 2. payload ──────┘   └────────── 3. signature ──────────┘

1. Header

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

The signing algorithm and token type. Common algorithms: HS256 (HMAC with SHA-256, symmetric), RS256 (RSASSA-PKCS1-v1_5 with SHA-256, asymmetric), ES256 (ECDSA with P-256, asymmetric).

2. Payload

{
  "sub": "user_1042",
  "name": "Ada Lovelace",
  "role": "admin",
  "iss": "https://auth.example.com",
  "aud": "api.example.com",
  "iat": 1758000000,
  "exp": 1758003600
}

The claims. Registered claims (iss, sub, aud, exp, nbf, iat, jti) are defined by RFC 7519; everything else is application-defined.

3. Signature

Computed over the first two segments:

HS256:  HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
RS256:  RSA-SHA256(  base64url(header) + "." + base64url(payload), private_key )

The signature covers the exact bytes of the first two segments. Change one character in the payload and the signature no longer validates.

Encoded is not encrypted

This is the most consequential fact about JWTs and the source of most incidents. The header and payload are Base64URL-encoded, not encrypted. Anyone who has the token can read every claim in it in about a second:

echo 'eyJzdWIiOiIxMjM0NTY3ODkwIn0' | base64 -d
# {"sub":"1234567890"}

There is no key involved. No brute force. It is a decoding, not a decryption.

What the signature provides is integrity and authenticity: proof that the token was issued by someone holding the key, and that it has not been modified since. It provides no confidentiality whatsoever.

The practical rules that follow:

  • Never put a secret in a JWT payload. No passwords, no API keys, no tokens, no session secrets.
  • Be careful with personal data. A JWT travels through URLs, logs, browser storage and error reports. Putting PII in one has privacy implications beyond the security question.
  • If you genuinely need confidentiality, use JWE (JSON Web Encryption) — a separate specification that actually encrypts the payload. It is far less commonly used, and in most systems the right answer is simply not to put anything sensitive in the token.

Verification versus decoding

Decoding is string manipulation anyone can perform. Verification requires the key, and it is the step that provides security. Conflating them is the root of several CVEs.

The server-side rule is absolute: verify the signature before reading a single claim. An unverified token is untrusted user input, exactly like a form field. Reading claims from an unverified token and acting on them is equivalent to trusting a client-supplied is_admin flag.

In practice, correct verification means:

// Node (jsonwebtoken) — pass the algorithm explicitly
jwt.verify(token, publicKey, { algorithms: ["RS256"], audience: "api.example.com",
                               issuer: "https://auth.example.com" });

# Python (PyJWT)
jwt.decode(token, public_key, algorithms=["RS256"],
           audience="api.example.com", issuer="https://auth.example.com")

Note the algorithms parameter. That single argument is what prevents the algorithm confusion attack described below. Most libraries also verify exp and nbf automatically — but verify that yours does.

The attacks JWTs are actually vulnerable to

Algorithm confusion (RS256 → HS256)

Suppose a server signs with RS256: it holds a private key, and verifiers hold the matching public key. If the verification code trusts the token's alg header instead of pinning the expected algorithm, an attacker can:

  1. Take the public key — which is public, by definition.
  2. Create a token with alg: HS256 and arbitrary claims.
  3. Sign it with HMAC using the public key as the HMAC secret.
  4. Send it to the server.

A library that trusts the header will HMAC-verify using the public key it already has, succeed, and accept the forged token. Fix: always pass the expected algorithm explicitly to your verification call.

alg: none

Some libraries historically accepted tokens declaring alg: none — unsigned — and treated them as valid. That means anyone can issue arbitrary claims. Modern libraries reject none by default, but older code and some configurations do not. Fix: pin the algorithm; never allow none.

Weak HMAC secrets

HS256 uses a shared secret. If it is short or guessable, an attacker with one valid token can brute-force it offline — no rate limit, no detection, as fast as their hardware allows. Once found, they can mint any token. Fix: use a random secret of at least 32 bytes, and prefer asymmetric algorithms when the verifier does not need to sign.

Missing or ignored exp

A token with no exp is valid forever. If your code does not require exp, a single leaked token grants permanent access. Fix: reject tokens without exp unless you have a specific reason not to.

Trusting client-supplied roles

A role claim is data, not authorisation. If a user's role is downgraded in your database but their token still says admin, and your API trusts the claim, they keep admin access until the token expires. Fix: make authorisation decisions against server-side state, or use short token lifetimes so the window is small.

Token leakage through logs and referrers

JWTs in URLs end up in access logs, browser history, and Referer headers sent to third parties. Fix: send tokens in the Authorization header, never in a query string.

Claims you must check

ClaimMeaningWhat to do
expExpiration timeReject if past. Reject if missing.
nbfNot valid beforeReject if in the future.
iatIssued atUseful for age checks and debugging.
issIssuerReject if not your trusted issuer.
audAudienceReject if not intended for your service.
subSubjectIdentify the user; do not trust it as authorisation.
jtiToken IDUse for revocation lists and replay detection.

The aud check is skipped most often and matters more than it looks. Without it, a token issued by the same identity provider for a different service in your organisation will authenticate successfully against yours.

On clock skew: exp is an absolute Unix timestamp, so if the issuing machine's clock is even a minute ahead of the verifier's, freshly issued tokens are rejected. Most libraries accept a small leeway — 30 to 60 seconds is typical and reasonable. Ensure your hosts run NTP.

Expiry is not revocation

A JWT is self-contained: a verifier needs no server-side state to validate it. That statelessness is the main reason to use JWTs, and it is also precisely why revocation is hard. Once issued and signed, a token remains valid until exp. Logging out does not invalidate it. Deleting it from the browser does not invalidate it — the token is still out there, and anyone who copied it can still use it.

The standard mitigation is a two-token design:

  • Access token — short-lived (5–15 minutes), sent with every request, verified statelessly. Because its lifetime is short, the damage window after leakage is bounded.
  • Refresh token — long-lived, stored server-side, revocable. Used only to mint new access tokens. Because the server looks it up, genuine logout is possible: delete the refresh token and access stops within minutes.

If you need immediate revocation, add a denylist keyed on jti, checked on every request. That costs you some statelessness — the check is a lookup — but it is the only way to get real-time invalidation with self-contained tokens.

Where to store a token

Both common browser options have real trade-offs, and anyone telling you one is obviously correct is oversimplifying.

localStorage

Simple, survives reloads, and works cleanly with single-page apps. The problem: any Cross-Site Scripting vulnerability on your site reads it. Given how easily XSS arrives through a transitive dependency or an unsanitised render, this is a genuine risk. A long-lived token in localStorage is the worst combination.

HttpOnly; Secure; SameSite cookie

Inaccessible to JavaScript, so XSS cannot exfiltrate it. The cost: you must handle CSRF, because the browser attaches cookies automatically. SameSite=Strict or SameSite=Lax plus a CSRF token for state-changing requests covers this.

The pattern most teams settle on

Keep the access token in memory (a JavaScript variable — lost on reload, which is fine because the refresh token restores it), and keep the refresh token in an HttpOnly cookie. XSS cannot read the refresh token, and the access token's short lifetime bounds the damage if it is captured.

What you should never do: put a long-lived JWT in localStorage.

JWT versus sessions

The trade-off is genuine and the popular "just use JWTs" advice is often wrong.

SessionsJWTs
Server stateYes — a store lookupNo — stateless verification
RevocationImmediateHard; needs short expiry + refresh
Scale-outNeeds shared session storeTrivially horizontal
Per-request costStore lookupCryptographic verification
Payload sizeTiny IDLarger — sent every request
Cross-serviceNeeds shared storeNatural fit

Choose sessions for a conventional web application with a single backend. You get instant revocation, smaller requests, and simpler security reasoning. The "JWTs scale better" argument is weak here — a Redis session store scales fine.

Choose JWTs when you genuinely need stateless verification across services, when the token must carry claims to a party that cannot look them up, or for short-lived single-purpose tokens — password resets, email verification, a signed download link.

The common mistake is adopting JWTs for a single-server web app because they are modern, then spending months working around the revocation problem you introduced.

To inspect a token's contents while developing, use our JWT Decoder — it runs entirely in your browser and deliberately does not verify signatures, since that would require your key.

Frequently asked questions

No. It is Base64URL-encoded, which anyone can reverse in one command. The signature prevents tampering, not reading. Never put secrets in a payload; use JWE if you genuinely need confidentiality.

Access tokens typically 5–15 minutes. Short lifetimes are the main mitigation for the fact that JWTs cannot be revoked. Use a longer-lived refresh token stored server-side for continuity.

Not the token itself — it remains valid until it expires. You can revoke the refresh token that mints new ones, or maintain a denylist keyed on the jti claim and check it on every request.

HS256 is simpler but requires every verifier to hold the shared secret, which means anyone who can verify can also forge. RS256 keeps signing capability private and lets verifiers hold only a public key — generally the better choice across service boundaries.

It works but exposes the token to any XSS on your site. A safer pattern is an access token in memory plus a refresh token in an HttpOnly cookie. Never store a long-lived JWT in localStorage.

Often not. For a conventional single-backend application, sessions give you instant revocation and simpler reasoning. JWTs earn their complexity with cross-service stateless verification or short-lived single-purpose tokens.