JWT Tokens Explained: Structure, Security, and Common Pitfalls

JWT Tokens Explained: Structure, Security, and Common Pitfalls

A JWT looks like random text until you decode it. Paste one into the JWT Decoder and suddenly three distinct sections appear: a header, a payload full of readable claims, and a signature. Understanding what each part does — and what it doesn't do — is essential for using JWTs correctly and securely.

The Three-Part Structure

Every JWT consists of three Base64url-encoded sections separated by dots:

Header Payload Signature eyJhbGciOiJIUzI1NiIs In5cCI6IkpXVCJ9 eyJzdWIiOiJ1c2VyXzEy MyIsImV4cCI6... SflKxwRJSMeKKF2Q T4fwpMeJf36POk6... . . Base64url(JSON) { "alg": "HS256", "typ": "JWT" } Base64url(JSON) { "sub": ..., "exp": ..., "roles": ... } Base64url of HMAC/RSA over header.payload
A JWT is three Base64url segments joined by dots. Anyone can decode the first two; only the signature proves authenticity.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MzYwMDAwMDAsImV4cCI6MTczNjA4NjQwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

That's: header.payload.signature

Each part is Base64url encoded — a URL-safe variant of Base64 that uses - and _ instead of + and /, and omits padding characters. It's not encryption. Anyone can decode the header and payload. The signature is what provides integrity guarantees.

The Header

Decoded, the header looks like this:

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

The alg field tells the verifying party which algorithm was used to sign the token. typ is almost always "JWT". That's it — the header is just metadata about the token format.

The Payload

The payload contains claims — statements about the subject of the token and additional metadata. Some claim names are standardized by RFC 7519:

{
  "sub": "user_123",
  "email": "alice@example.com",
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com",
  "iat": 1736000000,
  "exp": 1736086400,
  "roles": ["admin"]
}

Here's what the standard claims mean:

  • sub (subject) — who the token is about, usually a user ID
  • iss (issuer) — who created the token, typically your auth server URL
  • aud (audience) — who the token is intended for, typically your API or app
  • iat (issued at) — Unix timestamp of when the token was created
  • exp (expiration) — Unix timestamp after which the token should be rejected
  • nbf (not before) — Unix timestamp before which the token should be rejected (optional)

You can add any custom claims you need alongside these. The roles array above is a custom claim.

The Signature

flowchart LR
  H["header (JSON)"] --> EH["base64url(header)"]
  P["payload (JSON)"] --> EP["base64url(payload)"]
  EH --> Cat["header_b64.payload_b64"]
  EP --> Cat
  Key["secret or private key"] --> Sign[["HMAC-SHA256<br/>or RSA-SHA256"]]
  Cat --> Sign
  Sign --> Sig["base64url(signature)"]
  Sig --> Out["header_b64.payload_b64.signature_b64"]

The signature is generated by taking the encoded header, a dot, the encoded payload, and running them through the signing algorithm with a secret or private key:

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

The signature confirms two things: the token came from a trusted issuer, and the header and payload haven't been tampered with. Change even one character in the payload and the signature check fails.

HS256 vs RS256: Symmetric vs Asymmetric

flowchart TB
  subgraph HS["HS256 — shared secret"]
    direction LR
    I1["Issuer<br/>(holds secret)"] -- signs --> T1[JWT]
    T1 -- verifies with same secret --> V1["Verifier(s)<br/>(also holds secret)"]
  end
  subgraph RS["RS256 — key pair"]
    direction LR
    I2["Issuer<br/>(holds private key)"] -- signs --> T2[JWT]
    T2 -- verifies with public key --> V2["Verifier(s)<br/>(holds public key only)"]
  end

HS256 (HMAC-SHA256) uses a single shared secret. Both the issuer and the verifier need it — which means any service that verifies tokens also has the ability to forge them.

RS256 (RSA-SHA256) uses a private/public key pair. The issuer signs with the private key; anyone can verify with the public key. Only the issuer can create valid tokens. That's the right model when multiple services verify tokens, or when you publish a public JWKS endpoint.

For single-service setups, HS256 is fine. For distributed systems or third-party integrations, RS256 is the safer default.

Where to Store JWTs

This is where most tutorials disagree, so here's the practical breakdown:

localStorage — Easy to use from JavaScript, but vulnerable to XSS attacks. If an attacker can inject a script into your page, they can read the token and exfiltrate it. For any application handling sensitive data, localStorage is a poor choice.

sessionStorage — Same XSS vulnerability as localStorage. Token disappears when the tab closes, which is slightly safer but still not great.

HTTP-only cookies — The token is stored in a cookie with the HttpOnly flag, meaning JavaScript can't read it. This defeats XSS token theft. The tradeoff is CSRF vulnerability, which you mitigate with the SameSite=Strict or SameSite=Lax cookie attribute and CSRF tokens for state-changing requests.

For most web applications, HTTP-only cookies with SameSite protection is the recommended approach.

Common Security Pitfalls

Algorithm Confusion Attacks

sequenceDiagram
  participant A as Attacker
  participant S as Server (configured for RS256)
  Note over S: Trusts whatever alg the header claims
  A->>A: Take server's public key (it's public)
  A->>A: Forge token: header { alg: "HS256" }<br/>+ desired payload<br/>+ HMAC-SHA256(header.payload, public_key)
  A->>S: Bearer <forged token>
  S->>S: Reads alg=HS256 from header,<br/>uses public_key as HMAC secret
  S-->>A: Accepted as legitimate
  Note over S: Fix: pin algorithm server-side, ignore header.alg

Two distinct attacks exploit the alg field in the JWT header — both stem from letting the token itself dictate how it should be verified.

The alg: none attack: some early JWT libraries accepted a token with "alg": "none" in the header and would skip signature verification entirely, treating the token as implicitly trusted. Mainstream libraries patched this long ago, but hand-rolled JWT validation code can still be vulnerable. Always reject tokens that claim alg: none unconditionally.

The HS256/RS256 confusion attack: if a server is configured to verify tokens using RS256 (asymmetric, public key), an attacker can craft a token signed with HS256 using the server's public key as the HMAC secret. Libraries that blindly trust the alg field in the header will then verify the HMAC-SHA256 signature against a key they already know — and accept it. The fix is the same: explicitly specify the expected algorithm server-side and reject any token whose header claims a different one.

Always pin the expected algorithm in your verification code. Don't let the token header dictate it.

Storing Sensitive Data in the Payload

The payload is Base64url-encoded, not encrypted. Anyone who gets the token can read the claims without any key. Use the Base64 tool to verify this yourself — decode any JWT payload and it's plain JSON.

Don't put passwords, credit card numbers, SSNs, or other sensitive data in JWT claims. Stick to identifiers (user ID, roles, permissions) that you'd be comfortable showing to the token holder.

Missing or Overly Long Expiration

A token without an exp claim is valid forever if the signature holds — which is almost never what you want. Short-lived access tokens (15 minutes to 1 hour) with refresh token rotation is the standard pattern.

Don't go so short that it creates a frustrating user experience, though. Find a balance based on actual security requirements, not just defaults.

Not Verifying the Signature

This sounds obvious, but bugs happen. Always verify the JWT signature before trusting any claims. Never decode-and-use without verify.

Inspecting Tokens During Development

When debugging auth issues, you'll spend a lot of time staring at JWT strings. The JWT Decoder decodes a token into readable JSON without needing your secret — useful for quickly checking what claims a token contains, whether it's expired, and what algorithm was used.

For generating test hashes or checking token-related signatures, the Hash Generator can produce HMAC-SHA256 values to cross-check expected signatures manually.

If you want to go deeper on the encoding side, read Base64 Encoding Explained — it covers exactly how the Base64url encoding used in JWTs works, including why the variant uses different characters than standard Base64.

The broader OAuth/OIDC ecosystem that typically issues JWTs is covered in How OAuth Works.

Wrapping Up

JWTs are a solid choice for stateless authentication when you understand their limits: the payload is readable by anyone, short expiry matters, and the signing algorithm should be pinned server-side. For debugging token issues in development, the JWT Decoder is the fastest way to inspect what's inside without writing a line of code.

FAQ

Are JWTs encrypted?

No. By default JWTs are signed, not encrypted. The header and payload are Base64url-encoded, which is trivially reversible — anyone with the token can read every claim. If you need confidentiality, look at JWE (JSON Web Encryption) or simply don't put sensitive data in the payload.

Should I use JWT or session cookies in 2026?

For most monolithic web apps with a single backend, server-side sessions stored in Redis or your database are simpler and easier to revoke. JWTs shine when you have multiple services, mobile apps, or third-party integrations that all need to verify identity without calling a central session store. Pick sessions by default; reach for JWTs when statelessness genuinely buys you something.

How do I revoke a JWT before it expires?

You can't, not directly — that's the tradeoff for statelessness. The common workarounds are short-lived access tokens (5–15 minutes) paired with refresh tokens you can revoke server-side, or maintaining a server-side blocklist of jti claims for emergency revocation. If revocation is a hard requirement, a stateful session is usually the better fit.

Is HS256 secure enough, or do I need RS256?

HS256 with a strong random 256-bit secret is cryptographically secure. The choice is about key distribution, not strength: HS256 means anyone who can verify can also forge, so it only suits a single trust boundary. Use RS256 (or EdDSA) the moment a second party — a different microservice, a mobile client doing local validation, a federated partner — needs to verify tokens.

How long should a JWT live?

Access tokens: 5–60 minutes. Refresh tokens: hours to weeks, depending on sensitivity. Anything claiming "exp": never or multi-day access tokens is a smell — you've taken on session-like risk without the ability to revoke. Banking apps lean toward 5 minutes; consumer SaaS often uses 15–60 minutes.

Can I trust the iss and aud claims to identify the token?

Only after you verify the signature. Until the signature checks out, every byte in the payload is attacker-controlled. The right order is: parse header, pin the expected algorithm, verify signature with the key bound to the expected issuer, then check exp, iss, aud, and nbf. Skipping any of those steps is how the famous JWT vulnerabilities happen.

What's the difference between a JWT and an opaque bearer token?

A JWT carries its own claims — the API can extract user ID and roles directly without a database lookup. An opaque token (a random string) is just a key into a server-side store; the API has to call the auth server to learn anything about it. JWTs trade revocability for performance and decentralization.

Should I put roles and permissions in the JWT payload?

Coarse-grained roles like admin or editor are fine and standard practice. Fine-grained permission lists ("can_edit_invoice_4711") get unwieldy fast and make the token bloat — once it crosses ~4KB you'll have header-size problems with cookies and load balancers. Keep claims small and resolve detailed permissions server-side from the user ID.