Deep Dives11 min read

JWT Tokens Explained: How They Work and How to Decode Them

toolsto.dev
JWT Tokens Explained: How They Work and How to Decode Them

It's 2am, your users can't log in, and the error logs say "invalid token." You pull the JWT from the request headers, but it's a 300-character string that looks like gibberish. Where do you even start?

This guide breaks down exactly how JWTs work, how to debug them when they break, and the security mistakes that keep showing up in production.

What a JWT Actually Is

A JSON Web Token (JWT, pronounced "jot") is a signed JSON object encoded as a URL-safe string. It's defined in RFC 7519 and has become the default token format for web authentication.

The core idea: instead of storing session data on the server, encode it directly in the token. The server signs the token with a secret key so it can verify the token hasn't been tampered with — without looking anything up in a database.

Here's a real JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfYTFiMmMzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzA2MTQwODAwLCJleHAiOjE3MDYxNDQ0MDB9.7K8G3j_R2F0xBab30RMHrHDcEfxjoYZgeFONFh7HgQ

Looks random, but it's three Base64URL-encoded segments separated by dots. Paste that into the JWT decoder and you'll see exactly what's inside.

The Three Parts, Decoded

Part 1: Header

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

The header declares two things: what type of token this is (JWT) and what algorithm was used to sign it (HS256). That's it.

Common algorithms:

AlgorithmTypeKeyBest For
HS256Symmetric (HMAC + SHA-256)Single shared secretMonolithic apps where one server signs and verifies
HS384/HS512Symmetric (HMAC + SHA-384/512)Single shared secretSame as HS256 with stronger hash
RS256Asymmetric (RSA + SHA-256)Private key signs, public key verifiesMicroservices, third-party integrations
ES256Asymmetric (ECDSA + P-256)Private key signs, public key verifiesSame as RS256 but smaller keys and signatures
EdDSAAsymmetric (Ed25519)Private key signs, public key verifiesModern systems wanting best performance + security

For HS256, the signing process uses HMAC — a cryptographic function that combines a secret key with the token content to produce a signature. If you change even one character in the header or payload, the HMAC output changes completely.

Part 2: Payload (Claims)

{
  "sub": "usr_a1b2c3",
  "role": "admin",
  "email": "ada@example.com",
  "iat": 1706140800,
  "exp": 1706144400
}

The payload contains claims — statements about the user. There are three types:

Registered claims (defined by the JWT spec):

ClaimNameExamplePurpose
subSubject"usr_a1b2c3"Who this token is about (user ID)
issIssuer"auth.example.com"Who created this token
audAudience"api.example.com"Who this token is intended for
expExpiration1706144400When this token expires (Unix timestamp)
iatIssued At1706140800When this token was created
nbfNot Before1706140800Token isn't valid before this time
jtiJWT ID"a1b2c3d4"Unique token identifier (for revocation/replay prevention)

Public claims — custom claims with globally unique names (e.g., "https://example.com/roles").

Private claims — custom claims agreed upon between parties (e.g., "role", "team_id", "permissions").

The exp and iat values are Unix timestamps. If you need to check when a token expires, convert the timestamp — the JWT decoder does this automatically and flags expired tokens.

Part 3: Signature

The signature is what makes JWTs trustworthy. For HS256:

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

The server:

  1. Takes the encoded header and payload (the first two parts of the token)
  2. Runs them through HMAC-SHA256 with a secret key
  3. Compares the result to the signature in the token

If they match, the token is authentic and unmodified. If any bit of the header or payload was changed — even adding a space — the signature won't match.

For asymmetric algorithms (RS256, ES256), the process is similar but uses a private/public key pair. The auth server signs with the private key, and any service with the public key can verify.

The Authentication Flow (Step by Step)

Here's what actually happens in a typical JWT-based auth system:

1. Client sends credentials
   POST /login { "email": "ada@example.com", "password": "..." }

2. Server verifies credentials against database
   ✓ Password matches bcrypt hash

3. Server creates JWT
   Header:  { "alg": "HS256", "typ": "JWT" }
   Payload: { "sub": "usr_a1b2c3", "role": "admin", "exp": 1706144400 }
   Sign with secret key → eyJhbGci...

4. Server returns JWT to client
   { "access_token": "eyJhbGci...", "token_type": "bearer" }

5. Client stores JWT
   (HTTP-only cookie or in-memory variable)

6. Client sends JWT with every request
   GET /api/users
   Authorization: Bearer eyJhbGci...

7. Server verifies signature
   ✓ Signature valid, token not expired
   → Reads claims, processes request

8. No database lookup needed
   The user's ID and role are right there in the token

The performance advantage is real. Instead of hitting a session store on every request, the server just verifies a cryptographic signature — a sub-millisecond operation.

Decoding vs. Verifying: The Critical Difference

This catches junior developers off guard: JWT payloads are not encrypted. They're Base64URL-encoded, which means anyone can decode them.

// Anyone can do this — no key needed
const payload = JSON.parse(atob("eyJ1c2VyX2lkIjoxMjM0NX0"));
// { "user_id": 12345 }

Decoding = reading the payload. Anyone with the token can do this.

Verifying = checking the signature against a key. Only the server (with the secret) can do this.

The payload is Base64-encoded, not encrypted. Never put sensitive data (passwords, credit card numbers, SSNs) in a JWT payload. Anyone who intercepts the token can read it.

The Access Token + Refresh Token Pattern

Short-lived access tokens solve the revocation problem:

Access Token:  expires in 15 minutes, used for API requests
Refresh Token: expires in 7 days, used only to get new access tokens

Here's the flow:

1. User logs in → gets access token (15min) + refresh token (7d)
2. Client uses access token for API calls
3. Access token expires after 15 minutes
4. Client sends refresh token to /auth/refresh
5. Server issues new access token (and optionally new refresh token)
6. If refresh token is expired or revoked → user must log in again

Why this works for revocation: When you need to revoke a user's access (fired employee, compromised account), you invalidate their refresh token. They'll lose access within 15 minutes when their current access token expires and they can't get a new one.

Store refresh tokens in your database so you can revoke them. Store access tokens nowhere — they're stateless and self-validating.

Security Vulnerabilities (With Fixes)

1. The alg: "none" Attack

Some JWT libraries accept tokens with "alg": "none" — meaning no signature at all. An attacker modifies the payload, sets the algorithm to "none," removes the signature, and the server accepts it.

Fix: Always validate the algorithm on the server. Never accept "none". Most modern libraries reject it by default, but verify your configuration:

// ✅ Explicitly specify allowed algorithms
jwt.verify(token, secret, { algorithms: ["HS256"] });

// ❌ Don't let the token tell you which algorithm to use
jwt.verify(token, secret); // dangerous — trusts the token's alg header

2. Algorithm Confusion

If your server is configured for RS256 (asymmetric), an attacker might change the header to HS256 (symmetric) and sign the token using the public key as the HMAC secret. If the server's verification code doesn't enforce the algorithm, it might accept the forged token.

Fix: Always specify the expected algorithm in your verification code. Never trust the alg value from the token header.

3. Weak Secrets

For HMAC-based algorithms, a weak secret can be brute-forced:

# Tools like hashcat can crack weak JWT secrets
hashcat -m 16500 jwt.txt wordlist.txt

Fix: Use a cryptographically random secret of at least 256 bits (32 bytes). Generate one:

openssl rand -base64 32

4. Token Leakage via URL Parameters

Never pass JWTs in URL query strings:

# ❌ Token appears in server logs, browser history, Referer headers
GET /api/data?token=eyJhbGci...

Fix: Use the Authorization header or HTTP-only cookies.

5. Storing Tokens in localStorage

// ❌ Vulnerable to XSS — any script can read this
localStorage.setItem("token", jwt);

// ✅ Use HTTP-only cookies — inaccessible to JavaScript
// Set via server response header:
// Set-Cookie: token=eyJ...; HttpOnly; Secure; SameSite=Strict

JWTs vs. Session Cookies: When to Use Each

This is one of the most debated topics in web development. Here's a practical comparison:

DimensionJWTsSession Cookies
StateStateless — data is in the tokenStateful — data is on the server
Database loadNo session lookup neededSession lookup on every request
ScalabilityEasy — no shared state between serversNeed shared session store (Redis, database)
RevocationHard — token is valid until it expiresEasy — delete the session
Token sizeLarger (carries all claims, ~800-2000 bytes)Small (just a session ID, ~32 bytes)
Cross-domainEasy — send in Authorization headerHard — cookies are domain-bound
Mobile/API clientsNatural fit — header-based authAwkward — cookies are a browser concept

Use JWTs when:

  • Building APIs consumed by mobile apps or third-party clients
  • Running microservices that need to verify identity without a shared database
  • Implementing cross-domain authentication (SSO)
  • You need stateless, horizontally scalable auth

Use session cookies when:

  • Building a traditional server-rendered web app
  • You need instant revocation (e.g., "log out everywhere" button)
  • Token size matters (session IDs are tiny)
  • You're running a single server and don't need distributed auth

Many production systems use both: JWTs for API authentication between services, session cookies for the user-facing web app.

Debugging JWT Issues

When authentication breaks, here's the checklist:

  1. Decode the token — paste it into the JWT decoder to see the header and payload
  2. Check exp — is the token expired? This is the most common issue
  3. Check iat and server time — clock skew between servers causes verification failures
  4. Check the algorithm — does the token's alg match what the server expects?
  5. Check the audience/issuer — if you validate aud or iss, do they match?
  6. Check the secret/key — did someone rotate the signing key without updating all services?
  7. Check for whitespace — extra spaces or newlines in the token string cause parse failures

JWT Libraries by Language

LanguageLibraryNotes
Node.jsjoseModern, standards-compliant, supports all algorithms
Node.jsjsonwebtokenOlder but widely used, HS256/RS256 focus
PythonPyJWTClean API, supports all standard algorithms
Gogolang-jwt/jwtThe successor to dgrijalva/jwt-go
Javanimbus-jose-jwtComprehensive, enterprise-grade
RustjsonwebtokenFast, well-maintained
C#/.NETSystem.IdentityModel.Tokens.JwtBuilt into .NET

Key Takeaways

  1. JWTs are signed, not encrypted. Anyone can read the payload. Don't put secrets in it.
  2. Always verify the signature. Never decode without verifying. Never trust the alg header.
  3. Keep access tokens short-lived — 15 minutes is a good default. Use refresh tokens for longer sessions.
  4. Use HTTP-only cookies for browser storage. Never localStorage.
  5. Match the algorithm to your architecture — HS256 for monoliths, RS256/ES256 for microservices.
  6. When debugging, start by decoding the token to check expiration and claims. The JWT decoder makes this instant.

Sources: RFC 7519 — JSON Web Token (IETF), RFC 7518 — JSON Web Algorithms (IETF), OWASP JWT Security Cheat Sheet, Auth0 introduction to JWTs. Code examples tested in Node.js 22.

Related Tools