JWTs show up everywhere — API auth, OAuth flows, session replacements, password reset links — yet a surprising number of developers treat them as opaque magic strings. They aren't. A JWT is a transparent, self-describing data structure with one job: let a recipient trust a small blob of JSON without calling back to the issuer. Understanding the mechanics is the difference between using JWTs safely and shipping a token-shaped vulnerability.

What a JWT actually is

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

text
xxxxx.yyyyy.zzzzz header.payload.signature

Here's a real (truncated) example:

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBZGEiLCJleHAiOjE3MzU2ODk2MDB9.SflKxw_AdQssw5c

Notice there's no encryption happening. Each segment is just encoded — not hidden. Anyone holding this token can read the header and payload. That single fact drives most of the rest of this article.

base64url, not base64

The segments use base64url encoding, a URL-safe variant of base64. It swaps

text
+
and
text
/
for
text
-
and
text
_
, and drops the
text
=
padding. This matters because JWTs travel in URLs, HTTP headers, and cookies where
text
+
and
text
/
have special meaning. If you decode a JWT segment with a plain base64 decoder, it may choke on the missing padding or mangle the substituted characters.

base64url is encoding, not encryption — it's reversible by anyone, with no key. You can paste a token into a JWT decoder and read every claim instantly, or run a segment through a plain base64 decoder to see the raw JSON. That transparency is by design.

The header

Decode the first segment and you get something like:

json
{ "alg": "HS256", "typ": "JWT" }
  • text
    alg
    — the algorithm used to produce the signature (e.g.
    text
    HS256
    ,
    text
    RS256
    ,
    text
    ES256
    ).
  • text
    typ
    — the token type, usually
    text
    JWT
    .
  • text
    kid
    — an optional key ID telling the verifier which key to use when the issuer rotates keys.

The header is also where one of the oldest JWT attacks lives, which we'll get to.

The payload and its claims

The middle segment holds the claims — statements about an entity (usually a user) plus metadata. Decoded:

json
{ "sub": "12345", "name": "Ada", "role": "admin", "iss": "https://auth.example.com", "aud": "https://api.example.com", "iat": 1735603200, "exp": 1735689600 }

The spec defines a set of registered claims — short, standardized keys with agreed meaning:

  • text
    iss
    — issuer (who minted the token)
  • text
    sub
    — subject (who the token is about)
  • text
    aud
    — audience (who the token is for)
  • text
    exp
    — expiration time (Unix seconds)
  • text
    nbf
    — not-before time
  • text
    iat
    — issued-at time
  • text
    jti
    — a unique token ID, useful for revocation lists

Everything else is a public or private claim — your own application data like

text
role
or
text
name
.

Because the payload is readable by anyone, never put secrets in it. No passwords, no API keys, no personal data you wouldn't print on a postcard. A JWT proves the data hasn't been tampered with; it does nothing to keep the data private.

The signature: where trust comes from

The third segment is what makes a JWT trustworthy. The signature is computed over the encoded header and payload:

text
signature = HMAC_SHA256( base64url(header) + "." + base64url(payload), secret )

If an attacker edits the payload — say, flips

text
"role": "user"
to
text
"role": "admin"
— the signature no longer matches, because they don't have the secret needed to recompute it. The verifier recomputes the signature over the received header and payload and compares. Mismatch means reject.

This is the core promise of a JWT: integrity and authenticity, not confidentiality.

Symmetric vs asymmetric signing

There are two families of signing algorithms:

  • HMAC (HS256/384/512) — symmetric. The same secret signs and verifies. Simple, fast, fine when one party both issues and validates tokens. The catch: every service that verifies tokens needs the secret, and the secret can also forge tokens.
  • RSA / ECDSA (RS256, ES256, etc.) — asymmetric. A private key signs; a public key verifies. The auth server keeps the private key; every API can verify with the freely-distributable public key and can't mint tokens. This is the right choice for distributed systems and anything third parties consume.

As a rule: if more than one independent service verifies your tokens, prefer asymmetric signing.

Signing is not encryption

This trips people up constantly. A standard signed JWT (a JWS) is signed but not encrypted — fully readable. If you genuinely need the payload to be confidential in transit or at rest, you want JWE (JSON Web Encryption), a separate construction with five segments that actually encrypts the content.

In practice, most teams don't need JWE. They use a signed JWT over TLS and simply keep sensitive data out of the payload. Reach for JWE only when you have a hard requirement to hide claim contents from the holder.

How verification actually works

Verifying a JWT is more than checking the signature. A correct verifier does all of the following, and rejects the token if any step fails:

  1. Parse and split the token into its three segments.
  2. Inspect the header and pin the expected algorithm — don't blindly trust
    text
    alg
    .
  3. Recompute the signature with the correct key and compare using a constant-time comparison.
  4. Check
    text
    exp
    — reject if the current time is past expiry (allowing a small clock-skew leeway, e.g. 30–60 seconds).
  5. Check
    text
    nbf
    — reject if the token isn't valid yet.
  6. Check
    text
    iss
    — confirm the issuer is one you trust.
  7. Check
    text
    aud
    — confirm the token was minted for your service, not a different one.

Skipping

text
aud
and
text
iss
checks is one of the most common real-world mistakes: a token legitimately issued for service A gets happily accepted by service B that shares the same signing key.

Expiry, refresh, and revocation

JWTs are typically stateless — the server doesn't store them, it just verifies the signature on each request. That's the performance win, and also the hard part: a stateless token can't easily be revoked before it expires.

The standard pattern:

  • Issue a short-lived access token (minutes to an hour) carrying the claims APIs need.
  • Issue a long-lived refresh token, stored server-side, used only to mint new access tokens.
  • When a user logs out or is banned, invalidate the refresh token. The access token still works until
    text
    exp
    — which is why you keep
    text
    exp
    short.

If you need instant revocation of access tokens, you've partly given up statelessness: maintain a denylist of

text
jti
values, or check a fast revocation store on each request. That's a legitimate trade-off — just make it knowingly.

Decoding is not verifying

This is the single most important takeaway. Reading a JWT and trusting a JWT are completely different operations.

Decoding a JWT requires no key and proves nothing. Anyone can base64url-decode the payload, edit

text
"role": "admin"
, re-encode it, and hand you a perfectly well-formed token. It will decode cleanly. It will only fail verification if you actually check the signature against your key.

js
// DANGER: this trusts attacker-controlled data const payload = JSON.parse(atob(token.split('.')[1])); if (payload.role === 'admin') grantAccess(); // forgeable // CORRECT: verify the signature first, then read claims const claims = verifyJwt(token, publicKey, { algorithms: ['RS256'], issuer: 'https://auth.example.com', audience: 'https://api.example.com', }); if (claims.role === 'admin') grantAccess();

Use a decoder to inspect and debug tokens — that's exactly what the JWT decoder is for. Never use raw decoding as an authorization decision in production code.

Common security mistakes

The

text
alg: none
attack. Early JWT libraries honored a header of
text
"alg": "none"
, meaning "no signature." An attacker strips the signature, sets
text
alg
to
text
none
, and the token is accepted unverified. Fix: explicitly whitelist allowed algorithms in your verifier; never let the token's own header decide.

Algorithm confusion (RS256 → HS256). A server expects RS256 (asymmetric) and verifies with the RSA public key. An attacker changes

text
alg
to HS256 (symmetric) and signs the token using that public key — which is, after all, public — as the HMAC secret. A naive library uses the public key as an HMAC key and the forgery validates. Fix: pin the exact expected algorithm; don't derive it from the header.

Weak HMAC secrets. HS256 with a short, guessable secret is brute-forceable offline. Fix: use a high-entropy secret (32+ random bytes), or switch to asymmetric keys.

Skipping

text
exp
,
text
aud
, or
text
iss
.
A token that never expires, or one minted for another audience, becomes a long-lived skeleton key. Fix: validate all three on every request.

Putting secrets in the payload. It's readable. Don't.

Storing tokens carelessly in the browser. A JWT in

text
localStorage
is exposed to any XSS on the page. For browser sessions, prefer
text
HttpOnly
,
text
Secure
,
text
SameSite
cookies so script can't read the token.

The mental model to keep

A JWT is a tamper-evident envelope, not a locked box. It guarantees that claims came from someone holding the signing key and haven't been altered — nothing more. Keep secrets out of the payload, pin your algorithm, validate expiry and audience, and burn this line into memory: decoding is free and trust is earned only through verification. Get those right and JWTs are a clean, scalable way to carry identity. Get them wrong and you've handed users an editable permission slip.