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:

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

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

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

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.