Open a modern web application's network tab and you'll almost certainly spot an Authorization: Bearer header carrying a long, dot-separated string that looks like random noise. That's a JWT — and while most developers have used them for years, far fewer understand exactly what they are, what guarantees they provide, and how they can go catastrophically wrong. This guide covers everything from the three-part structure to the real-world attack vectors that have bitten production systems.
What Is a JWT Token?
JWT stands for JSON Web Token (pronounced "jot"). It's an open standard defined in RFC 7519 that specifies a compact, self-contained way to transmit information between parties as a JSON object. The key word is self-contained: a JWT carries its own claims — the user's ID, their roles, when the token expires — without needing a database lookup to verify them.
This is the core value proposition of JWTs over traditional session tokens. A session token is an opaque string that the server must validate against a session store (usually a database or Redis instance). A JWT is stateless — the server validates it using a cryptographic key, with no external lookup required. In a microservices architecture where a dozen services each need to verify the caller's identity, the difference between "call the auth service for every request" and "verify a signature locally" is enormous.
JWT vs Session Tokens: Stateless vs Stateful
The stateless nature of JWTs is a double-edged sword. On the benefit side, any service that possesses the public key (for RS256) or the shared secret (for HS256) can independently verify a token with no network call. This is ideal for distributed systems, API gateways, and cross-domain authentication where a central session store would become a bottleneck or a single point of failure.
On the downside, statelessness makes revocation hard. With a session token, you delete the session from the store and it immediately stops working. With a JWT, the only guaranteed way to invalidate a token before its expiry is to maintain a blocklist — a set of revoked token IDs (jti claims) that every verifier must check. At that point you've reintroduced the database lookup, and you need to ask whether you still gain anything over sessions. For most web applications without complex microservice topologies, traditional sessions are simpler, more revocable, and just as fast.
JWTs win clearly when: multiple independent services need to verify the same identity, you have a mobile app issuing tokens to a separate API, or you need short-lived tokens where revocation is not a concern because the token expires in minutes anyway. Sessions win when: you need instant revocation, the app is a monolith, and the complexity of key management is not justified.
The Three-Part Structure in Detail
A JWT is three base64url-encoded JSON objects concatenated with dots: header.payload.signature. Each part is independently decodable. The encoding is base64url (not standard base64) — it uses - instead of + and _ instead of /, with no padding characters, so the token is safe to embed in URLs without encoding.
Part 1: The Header
The header declares the token type and the cryptographic algorithm used to produce the signature:
{
"alg": "RS256",
"typ": "JWT"
}HS256 is HMAC-SHA256 — a symmetric algorithm where the same secret is used to sign and verify. Fast and simple, but every service that needs to verify tokens must possess the secret, which creates key distribution risk. RS256 is RSA-SHA256 — asymmetric. The auth server signs with a private key; any service can verify with the public key. This is the right default for production systems with multiple consumers. ES256 (ECDSA) gives the same asymmetric properties with significantly shorter key and signature sizes. Never use HS384 or HS512 expecting more security from the larger hash — the weak point is usually the secret strength, not the hash size.
Part 2: The Payload
The payload contains the claims. It is base64url-encoded JSON — it is not encrypted. Anyone who has the token can base64-decode the payload and read every claim in plain text. This is a fundamental misunderstanding that causes real security incidents. Never place passwords, payment information, PII, or anything sensitive in a JWT payload unless you are using a JWE (JSON Web Encryption, a separate spec) rather than a plain JWT.
{
"sub": "user_12345",
"iss": "https://auth.tanvrit.com",
"aud": "tanvrit-api",
"iat": 1711900800,
"exp": 1711904400,
"nbf": 1711900800,
"jti": "a8f3b2c1-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"roles": ["admin", "billing"],
"email": "user@example.com"
}Part 3: The Signature
The signature is computed by taking the base64url-encoded header, appending a dot, appending the base64url-encoded payload, then signing that string with the algorithm and key declared in the header. For HS256: HMAC-SHA256(secret, header + "." + payload). For RS256: RSA-SHA256(privateKey, header + "." + payload). The resulting bytes are base64url-encoded to form the third segment.
When a server receives a JWT, it independently recomputes the signature from the header and payload it received, then compares that computed value to the signature in the token. If they match, the header and payload have not been tampered with since the token was issued. If they differ, the token is rejected. This is why you cannot simply edit a JWT payload and re-use it — the signature will no longer match.
Standard Claims Deep Dive
The JWT specification defines a set of registered claim names. Using these standard names ensures interoperability between libraries and services. None are strictly required by the spec, but failing to validate them leaves critical security holes.
sub(Subject) — the principal that is the subject of the JWT, typically a user ID. This should be a stable, unique identifier — a UUID or database primary key, not an email address that the user might change.iss(Issuer) — the entity that issued the token. Your verifier should explicitly check that this matches the expected issuer. If you accept tokens from multiple issuers, maintain an allowlist and check against it.aud(Audience) — the intended recipient of the token. If your token is issued for the mobile API but a different service also trusts the same signing key, the audience claim prevents token reuse across services. Always validate this.exp(Expiration Time) — a Unix timestamp after which the token must be rejected. This is mandatory in any security-conscious implementation. Without it, a stolen token is valid forever.iat(Issued At) — when the token was created. Useful for implementing maximum token age policies independent of theexpvalue.nbf(Not Before) — the token must not be accepted before this Unix timestamp. Useful for scheduling token activation, or providing a small clock-skew buffer in distributed systems.jti(JWT ID) — a unique identifier for this specific token instance. The primary use case is revocation: store issuedjtivalues in a blocklist and check against it to invalidate specific tokens without rotating keys. Also useful for detecting replay attacks.
Common JWT Vulnerabilities
The alg:none Attack
This is the most infamous JWT vulnerability and it was present in many early JWT libraries. The attack works like this: an attacker takes a valid JWT, modifies the payload (e.g., changes their role from user to admin), changes the alg header field to "none", and strips the signature. Some libraries, treating "none" as a valid algorithm meaning "unsigned", would accept this token without verification.
The fix: always explicitly specify which algorithms your verifier accepts, and reject tokens with alg: none. In most modern libraries this means passing an algorithm allowlist to the verify function rather than relying on the algorithm declared in the token header.
Weak Signing Secrets
For HS256 tokens, the security of the token is entirely dependent on the entropy of the secret. A human-memorable secret like mysecret or password123 can be brute-forced offline — an attacker with a valid token can attempt millions of secrets per second without touching your server, using tools like Hashcat. The resulting signing key lets them forge arbitrary tokens.
The fix: use a cryptographically random secret of at least 256 bits (32 bytes). Generate it with openssl rand -base64 32 or the equivalent in your language. Store it in an environment variable, never in source code. Consider rotating to RS256/ES256 for production workloads so the signing key never needs to be distributed to verifiers.
Missing or Ignored Expiry
A JWT without an exp claim is valid forever. A JWT with an exp claim that the server doesn't validate is equally dangerous. Both are common mistakes. Always set a short expiry on access tokens — 15 to 60 minutes is a reasonable range for most applications — and always verify the exp claim on the server side.
Storing JWTs in localStorage
Any JavaScript running on your page can read from localStorage. A single XSS vulnerability — in your own code, a dependency, or even a third-party script you load — can exfiltrate every JWT your users hold. The attacker can then make authenticated API calls as your users from anywhere in the world until the token expires.
The safer approach for web applications is to store tokens in HttpOnly cookies with Secure and SameSite=Strict attributes. These cookies are inaccessible to JavaScript and are not sent on cross-origin requests. The tradeoff is that you must handle CSRF protection, but that is a well-understood problem with standard solutions.
The Refresh Token Pattern
Short-lived access tokens (15 minutes) solve the stolen-token window, but they create a usability problem: users would need to re-authenticate every 15 minutes. The refresh token pattern solves this. When the user authenticates, the server issues two tokens: a short-lived access token for API calls, and a long-lived refresh token (days to weeks) stored in an HttpOnly cookie. When the access token expires, the client silently calls a refresh endpoint with the refresh token to receive a new access token.
The refresh token can be rotated on every use (rotating refresh tokens) — the server issues a new refresh token each time and invalidates the old one. This means a stolen refresh token can only be used once before being detected: when the legitimate client tries to use the now-invalidated token, the server knows a theft occurred and can invalidate the entire session. This pattern, described in the OAuth 2.0 Security Best Current Practice, is the recommended approach for SPAs and mobile apps.
JWT in Mobile Apps vs SPAs
Mobile apps have a storage advantage over SPAs: both iOS (via Keychain) and Android (via Keystore) provide hardware-backed secure storage for cryptographic material. Storing a JWT or refresh token in the platform Keychain/Keystore is significantly more secure than any browser storage option. The OS enforces access controls at the hardware level, and the token cannot be extracted even from a rooted device in many cases.
For SPAs, the HttpOnly cookie approach described above is the current best practice. The alternative — the "BFF" (Backend for Frontend) pattern — involves a thin server-side proxy that handles token storage and forwards authenticated requests, keeping tokens off the browser entirely. This trades frontend simplicity for backend complexity, but it is the most secure option available for browser-based applications.
When Not to Use JWT
JWTs are overused. They are often chosen by default when a traditional server-side session would be simpler, more secure, and equally performant. Avoid JWTs when: your application is a monolith with a single backend service (sessions are simpler and fully revocable), when you need immediate revocation without maintaining a blocklist, when your tokens will be long-lived (the statelessness provides no benefit and the revocation limitation becomes a liability), or when the payload you need to carry is large (JWTs in HTTP headers are limited by header size limits in proxies and load balancers).
The right mental model: JWTs are an excellent solution for cross-service authentication in distributed systems, short-lived bearer tokens in OAuth flows, and mobile-to-API authentication. They are not a universal replacement for sessions.
Debugging JWT Issues in Production
The most common production JWT issues fall into a few predictable categories: clock skew between services causing premature expiry rejections, wrong audience claims because the token was issued for a different service, algorithm mismatches between issuer and verifier configuration, and corrupted tokens from improper URL encoding. When a JWT-related auth failure appears in your logs, the first step is always to decode the token and inspect every claim — not to guess.
Pay particular attention to the exp and iat values against the current server time, the aud value against what your service expects, and the alg header against your verifier's configuration. Most JWT auth bugs are visible within the first 30 seconds of looking at the decoded token.
Use Tanvrit's JWT decoder to instantly decode any JWT and inspect its header, payload, and expiry — without sending the token to any server. Everything runs in your browser, so you can safely paste tokens containing real user claims during a debugging session. Open the JWT Decoder →