JSON Web Tokens have become the de facto standard for authentication in modern web applications and APIs. If you have ever logged into a single-page application, called a protected API endpoint, or implemented OAuth, you have almost certainly worked with JWTs — even if you did not realize it. Despite their ubiquity, JWTs are frequently misunderstood and misused, leading to security vulnerabilities that could have been easily avoided. This guide breaks down how JWTs work, how to implement authentication flows correctly, and the critical security pitfalls you need to watch out for.
What Is a JWT?
A JSON Web Token (JWT, pronounced "jot") is an open standard (RFC 7519) for securely transmitting information between parties as a compact, URL-safe string. A JWT consists of three Base64URL-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|___________ Header ___________|.___________ Payload ___________|._______ Signature _______|
Let us examine each part in detail.
Header
The header is a JSON object that specifies the token type and the signing algorithm. When Base64URL-decoded, the header from the example above looks like this:
{
"alg": "HS256",
"typ": "JWT"
}
Common algorithms include HS256 (HMAC with SHA-256, symmetric) and RS256 (RSA with SHA-256, asymmetric). The choice between them has significant security and architectural implications, which we will cover later.
Payload
The payload contains claims — statements about the user and additional metadata. Claims come in three types:
- Registered claims — Standardized fields like
iss(issuer),sub(subject),exp(expiration),iat(issued at), andaud(audience). - Public claims — Custom claims registered in the IANA JWT Claims Registry to avoid collisions.
- Private claims — Application-specific claims agreed upon between parties (e.g.,
role,permissions).
{
"sub": "user_8x7k2m",
"name": "Alice Chen",
"email": "[email protected]",
"role": "admin",
"iat": 1706400000,
"exp": 1706403600
}
The payload is Base64URL-encoded, not encrypted. Anyone who possesses the token can decode and read its contents. Never put sensitive information like passwords, credit card numbers, or secrets in a JWT payload.
Signature
The signature ensures the token has not been tampered with. For HMAC-based algorithms, it is computed as:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
When the server receives a JWT, it recalculates the signature using its secret key and compares it to the signature in the token. If they match, the token is authentic and unmodified. If they differ, the token has been tampered with and must be rejected.
How JWT Authentication Works
Here is the typical authentication flow in a web application using JWTs:
- Login — The user sends their credentials (email + password) to the server's login endpoint.
- Verification — The server validates the credentials against the database (checking the hashed password).
- Token generation — If valid, the server creates a JWT containing the user's identity and permissions, signs it with a secret key, and returns it to the client.
- Storage — The client stores the token (typically in memory or an httpOnly cookie).
- Authenticated requests — For every subsequent API call, the client sends the JWT in the
Authorizationheader. - Validation — The server verifies the token's signature and checks that it has not expired before processing the request.
// Client sends credentials
POST /api/auth/login
Content-Type: application/json
{ "email": "[email protected]", "password": "s3cur3P@ss" }
// Server responds with token
HTTP/1.1 200 OK
{
"accessToken": "eyJhbGciOi...",
"expiresIn": 3600
}
// Client uses token for authenticated requests
GET /api/users/me
Authorization: Bearer eyJhbGciOi...
The key advantage of this flow is that the server does not need to store session state. The JWT itself contains all the information needed to authenticate the request, making JWTs inherently stateless. This is why they work so well with microservices and horizontally scaled architectures — any server instance can validate the token independently.
Access Tokens vs. Refresh Tokens
In production systems, you should use a two-token strategy to balance security with user experience:
Access Token
- Short-lived (5–15 minutes)
- Sent with every API request in the
Authorizationheader - Contains user identity and permissions
- If compromised, the damage window is limited to the token's short lifespan
Refresh Token
- Long-lived (7–30 days)
- Stored securely (httpOnly cookie, never in localStorage)
- Used only to obtain new access tokens when the current one expires
- Can be revoked server-side by maintaining a denylist or rotating on each use
// When the access token expires, use the refresh token
POST /api/auth/refresh
Cookie: refreshToken=dGhpcyBpcyBhIHJlZn...
// Server validates refresh token and issues new access token
HTTP/1.1 200 OK
{
"accessToken": "eyJhbGciOi... (new token)",
"expiresIn": 3600
}
This pattern means users stay logged in for extended periods without the security risk of long-lived access tokens. If a refresh token is compromised, the server can revoke it immediately.
Need to inspect or debug a JWT? Try our free JWT Decoder — paste any token to instantly view its header, payload, and verify the signature.
Open JWT Decoder →Security Pitfalls and How to Avoid Them
JWTs are frequently misused in ways that create serious security vulnerabilities. Here are the most critical mistakes to avoid:
1. The "alg: none" Attack
The JWT spec allows an "alg": "none" header, which means the token has no signature. If your server's JWT library accepts unsigned tokens, an attacker can forge any token by simply setting the algorithm to none and omitting the signature. Always explicitly specify which algorithms your server accepts and reject tokens with unexpected algorithms:
// Node.js with jsonwebtoken — ALWAYS specify algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] });
// NEVER do this — accepts any algorithm including "none"
jwt.verify(token, secret);
2. Algorithm Confusion (RS256 vs HS256)
If your server expects RS256 (asymmetric — signs with private key, verifies with public key) but an attacker changes the header to HS256 (symmetric), some libraries will use the public key as the HMAC secret. Since the public key is often publicly available, the attacker can forge valid tokens. The fix is the same: explicitly whitelist accepted algorithms.
3. Storing Tokens in localStorage
localStorage is accessible to any JavaScript running on the page, making tokens stored there vulnerable to Cross-Site Scripting (XSS) attacks. A single XSS vulnerability means an attacker can steal every user's JWT. Instead:
- Store access tokens in memory (a JavaScript variable) — they disappear when the page is refreshed, but that is what refresh tokens solve.
- Store refresh tokens in httpOnly, Secure, SameSite cookies — JavaScript cannot access them, making them immune to XSS.
4. Not Validating All Claims
Always validate these claims when verifying a JWT:
exp— Reject expired tokens. This seems obvious, but some implementations skip it.iss— Verify the token was issued by your server, not a different service.aud— Verify the token was intended for your application. This prevents a token issued for Service A from being used to authenticate with Service B.
5. Putting Too Much Data in the Token
Every byte in a JWT is sent with every HTTP request. A token stuffed with permissions, user profile data, and application state can easily exceed 4 KB, which is the cookie size limit and adds meaningful overhead to every API call. Keep payloads minimal — include only the user identifier and essential claims, and fetch additional data from the database when needed.
Symmetric vs. Asymmetric Signing
Choosing the right signing algorithm affects your architecture:
- HS256 (Symmetric) — Uses a single shared secret for both signing and verification. Simpler to set up, but every service that needs to verify tokens must have access to the secret. Suitable for monolithic applications or when all services are equally trusted.
- RS256 / ES256 (Asymmetric) — Uses a private key to sign and a public key to verify. Only the authentication service needs the private key; all other services verify tokens with the public key (which can be safely distributed). This is the recommended approach for microservices architectures and when using third-party identity providers.
// RS256: Sign with private key (auth service only)
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// RS256: Verify with public key (any service)
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
JWT Best Practices Checklist
Follow these guidelines for a secure, production-ready JWT implementation:
- Keep access tokens short-lived (5–15 minutes). Use refresh tokens for session persistence.
- Always validate
exp,iss, andaudclaims on every request. - Explicitly whitelist allowed algorithms in your verification logic. Never accept
"alg": "none". - Use RS256 or ES256 for microservices architectures where multiple services verify tokens.
- Store access tokens in memory, refresh tokens in httpOnly cookies. Never use localStorage for tokens.
- Implement refresh token rotation — issue a new refresh token with each use and invalidate the old one. This limits the damage if a refresh token is stolen.
- Keep payloads minimal. Include
sub,role, andexp— not the user's entire profile. - Use HTTPS everywhere. Tokens sent over unencrypted connections can be intercepted trivially.
- Plan for token revocation. Maintain a server-side denylist for compromised tokens, or use short expiration times so revocation is rarely needed.
- Rotate signing keys periodically and support key rollover using the
kid(Key ID) header parameter and a JWKS (JSON Web Key Set) endpoint.
When Not to Use JWTs
JWTs are not the right tool for every situation:
- Server-rendered applications with simple session needs — Traditional server-side sessions with a session cookie are simpler, easier to revoke, and work perfectly for monolithic apps. Do not add JWT complexity unless you need statelessness.
- When you need instant revocation — Since JWTs are stateless, you cannot truly revoke them without a server-side denylist (which partially defeats the statelessness benefit). If immediate session termination is a hard requirement, consider opaque tokens validated against a session store.
- As a replacement for session storage — JWTs carry data, but cramming too much state into them creates bloated, fragile tokens. Use them for identity and authorization, not as a general-purpose data transport.
Conclusion
JWTs are a powerful tool for building stateless authentication in modern applications, but they demand careful implementation. Understanding the three-part structure, using short-lived access tokens with refresh token rotation, explicitly whitelisting algorithms, and storing tokens securely are the fundamentals that separate a robust auth system from a vulnerable one.
The next time you are debugging an authentication issue, decode the token first — inspecting the header, payload, and expiration will often reveal the problem immediately. And remember: JWTs are not magic. They are a tool with clear strengths and well-documented risks. Use them wisely.