JWT

JWT Explained for Developers

A JWT is three base64-encoded strings separated by dots. What's in them, how signatures actually work, why "stateless auth" is a half-truth, and the mistakes that keep showing up in production code.

Emilian GheoneaMay 8, 2026 7 min read

A JSON Web Token is three base64url-encoded strings glued together with dots. That's the entire format. Once you've seen one, you can spot them everywhere:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The three sections are:

  1. Header — JSON describing the algorithm and token type.
  2. Payload — JSON containing the claims (who, what, when).
  3. Signature — a cryptographic proof that the first two parts haven't been tampered with.

That structure is straightforward. Everything that goes wrong with JWTs comes from misunderstanding what each piece guarantees — and, more often, what it does not.

Anatomy, in detail

Let's decode the example above.

Header

Base64url-decoding the first segment gives:

{ "alg": "HS256", "typ": "JWT" }
  • alg is the signing algorithm. Common values: HS256 (HMAC-SHA-256, symmetric), RS256 (RSA-SHA-256, asymmetric), ES256 (ECDSA, asymmetric).
  • typ is the token type. Almost always "JWT".

Sometimes you'll see kid (key ID), pointing at which key to use when the issuer rotates them.

Payload

The second segment decodes to a JSON object of claims:

{
  "sub": "1234",
  "iss": "https://auth.example.com",
  "aud": "api.example.com",
  "iat": 1715000000,
  "exp": 1715003600,
  "scope": "read:calendar"
}

Most of those keys are reserved by RFC 7519:

  • sub — subject. The user ID.
  • iss — issuer. Who minted the token.
  • aud — audience. Who the token is for. Reject tokens not addressed to you.
  • iat — issued-at, Unix seconds.
  • exp — expiration, Unix seconds.
  • nbf — not-before. Token isn't valid before this time.
  • jti — JWT ID. A unique identifier for the token, useful for revocation.

You can put any other JSON-serializable values in here. Don't put secrets. The payload is base64-encoded, not encrypted. Anyone who has the token can read everything in it.

Signature

This is where the security lives. The signature is:

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

Or for RS256:

RSA-Sign( base64url(header) + "." + base64url(payload), privateKey )

The receiver re-computes the signature using the same input and the appropriate key. If it matches, the token wasn't tampered with. If it doesn't, the token is forged or modified, and you reject it.

Rendering diagram…

A JWT is signed, not encrypted. If you need confidentiality (the payload contains sensitive data), you want a JWE — a different RFC, a different format — or, more pragmatically, you should keep the sensitive data on the server and only put a reference in the JWT.

Symmetric vs asymmetric: HS256 vs RS256

The single most consequential design decision when issuing JWTs.

HS256 (symmetric) uses one secret. The issuer signs with it; the verifier verifies with it. Whoever can verify can also forge. That's fine if the same service does both — your monolith issues a session token and verifies it on the next request. It's a problem the moment you have a separate API consuming tokens, because that API now has the issuing power too.

RS256 (asymmetric) uses an asymmetric key pair. The issuer signs with a private key. Consumers verify with the public key. Anyone can hold the public key and verify; only the issuer can mint new tokens.

Rendering diagram…

For systems with more than one service, use RS256 (or ES256, which is smaller and faster). For a single monolith, HS256 is fine and simpler to operate.

We've written more about the tradeoff in RS256 vs HS256.

The "stateless auth" half-truth

The pitch for JWTs in session contexts goes: the server doesn't need to store sessions; the token itself contains everything needed to authorize the request. This is true in a narrow sense and misleading in a broader one.

What's true: a JWT signed by your issuer carries the user's identity and permissions. Any service holding the public key can authorize the request without a database round-trip.

What's missing: revocation. If a user logs out, or you discover the token has been stolen, or you fire an employee — a stateless JWT keeps working until it expires. There's no list to remove it from.

Real systems solve this in a few ways, all of which add state back:

  • Very short-lived access tokens (5-15 minutes) paired with a refresh token that is tracked server-side. The blast radius of a leaked access token is small; revocation happens at the refresh layer.
  • A revocation list keyed by jti. Every JWT verification checks a denylist. Cheap with Redis; harder to scale globally.
  • Token versioning. Embed a tokenVersion in the JWT and store the user's current version in the database. Verification compares the two. Logout bumps the version, invalidating all old tokens. This adds a DB lookup but keeps revocation immediate.

"Stateless JWTs everywhere" is mostly a tutorial-grade architecture. Production auth systems are almost always hybrid.

How to verify a JWT correctly

The verification logic is more than "does the signature check out." A correct verifier does, in order:

  1. Parse the token into header, payload, signature.
  2. Reject if alg in the header is "none". Some libraries used to accept this. The none algorithm means "no signature," which means "anyone can forge anything." This has been the source of multiple real CVEs.
  3. Reject if alg doesn't match what you expect. Don't trust the token to tell you which algorithm to use. Pin the algorithm in your verifier configuration. The "alg confusion" attack flips an asymmetric token into a symmetric one by changing the header, then signs it with the public key (which the attacker has).
  4. Verify the signature with the expected key (resolve via kid if rotating).
  5. Check exp — token must not be expired. Allow a small clock skew (60 seconds is typical).
  6. Check nbf if present — token must not be from the future.
  7. Check iss — must match the expected issuer string exactly.
  8. Check aud — must include your service's identifier. If a JWT issued for service-A can authorize requests to service-B, you've built a confused deputy.
  9. Only now trust the claims.

Don't write any of that by hand. Use a library you trust (jose for JavaScript, python-jose for Python, golang-jwt/jwt/v5 for Go). Audit the library's defaults — some accept alg=none by default and have to be configured not to.

Things that go wrong

The bugs that show up in real codebases:

Putting sensitive data in the payload. "We base64-encoded it, no one can read it." Anyone with atob() can read it. JWT payloads are public to anyone holding the token.

Storing JWTs in localStorage. They're now reachable by any XSS in your application. Once an attacker has the token, they have the session. Use HttpOnly cookies for session JWTs in browser contexts. We have a longer treatment in JWT Security Mistakes.

Long-lived JWTs. A 30-day access token sounds convenient. It is, for an attacker who steals one. Access tokens should expire in minutes, not days. Use refresh tokens for the long-lived part of the session, and keep those server-side.

Forgetting to rotate keys. Asymmetric signing keys should rotate periodically. Have a kid in your header and resolve to a key set (JWKS endpoint or local map). When you rotate, the new key is signed against; old keys remain valid until tokens signed with them expire.

Trusting the JWT for authorization decisions that change frequently. A user's role embedded in a 1-hour JWT keeps working for an hour after you revoke it. If you're encoding permissions that must be revocable in seconds, either keep the JWT short-lived enough to not matter, or do a per-request authorization lookup against your source of truth.

Cross-issuer confusion. Your auth server issues tokens for api.example.com. A separate auth server (say, Google for "log in with Google") also issues tokens. A bug in your verifier accepts both. Now Google tokens can authorize calls to your API. Pin the issuer.

When not to use JWTs

JWTs are popular enough that people reach for them by default. They're not always the right tool.

Browser sessions for a single web app: an opaque session cookie pointing at a server-side session record is simpler, smaller, and easier to revoke. JWTs are a win when you have multiple services consuming the same token across origins.

API keys for backend services: the auth pattern is "this caller has these capabilities," and you usually want it revocable in seconds. An opaque key looked up in a database is often a better fit.

Anywhere you're tempted to put sensitive data in the payload "because the client doesn't decode it." The client absolutely decodes it. The browser DevTools decodes it. Anyone who pastes the token into jwt.io decodes it.

The right framing: a JWT is a portable, signed assertion of facts. Use it where portability and signature actually buy you something. Don't use it as a generic session cookie when an opaque session ID would do.

What to read next

JWTs are not magic, and they are not a replacement for thinking carefully about your session model. They're a sturdy primitive for carrying signed claims between services. Get the verifier right, keep them short-lived, and don't make them carry more than they should.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.