JWT tokens look intimidating — that long string with two dots, like someone mashed their keyboard and called it authentication. But once you decode one, you realize it's just JSON wearing a trenchcoat. I remember the first time I cracked one open in a debugger and thought "wait, that's it?" Three Base64 chunks, a bit of crypto, and suddenly you've got stateless auth. In this guide I'll break down exactly how they work, because once you see the internals, JWTs stop being scary and start being one of the most useful tools in your stack.
What Is a JWT?
A JSON Web Token is a compact, URL-safe token format defined in RFC 7519. It is used to securely transmit information between parties as a JSON object. The information can be verified and trusted because it is digitally signed — either using a secret (HMAC) or a public/private key pair (RSA or ECDSA).
JWTs are commonly used for:
- Authentication: After login, a JWT is issued and sent with subsequent requests to identify the user.
- Authorization: JWTs can carry permission claims that determine what resources a user can access.
- Information exchange: JWTs can securely transmit data between services in a microservices architecture.
JWT Structure: Three Parts
A JWT consists of three Base64URL-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c1. Header
The header contains metadata about the token — the signing algorithm and token type.
{
"alg": "HS256",
"typ": "JWT"
}2. Payload (Claims)
The payload contains the claims — statements about the entity (user) and additional data.
{
"sub": "1234567890",
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}3. Signature
The signature is created by encoding the header and payload, then signing them with the secret key. It verifies that the token has not been tampered with.
Standard JWT Claims
| Claim | Name | Description |
|---|---|---|
| iss | Issuer | Who issued the token (e.g., your auth server URL) |
| sub | Subject | The user or entity the token refers to |
| aud | Audience | Who the token is intended for |
| exp | Expiration | Unix timestamp when the token expires |
| nbf | Not Before | Token is not valid before this timestamp |
| iat | Issued At | Unix timestamp when the token was issued |
| jti | JWT ID | Unique identifier for the token (prevents replay attacks) |
JWT Security Best Practices
✅ Always verify the signature
Never trust a JWT without verifying its signature. Decoding a JWT only reads the payload — it does not verify authenticity.
✅ Set short expiration times
Use short-lived tokens (15–60 minutes) and implement refresh token rotation to limit the damage if a token is stolen.
❌ Never store JWTs in localStorage
localStorage is accessible to JavaScript and vulnerable to XSS attacks. Use httpOnly cookies for storing JWTs in browsers.
✅ Validate all claims
Always validate exp, iss, and aud claims on the server. Do not assume a valid signature means all claims are correct.
✅ Use strong signing keys
For HMAC (HS256), use a secret of at least 256 bits. For RSA/ECDSA, use appropriate key sizes (RS256: 2048-bit, ES256: P-256).
❌ Avoid the 'none' algorithm
Some JWT libraries accept 'alg: none' which disables signature verification. Always explicitly specify and validate the algorithm.
JWT in Practice: Authentication Flows
Understanding JWT structure is one thing — knowing how to use JWTs correctly in a real authentication system is another. This section covers the complete OAuth 2.0 + JWT flow, refresh token strategies, practical Node.js implementation, and how to handle the inherent statelessness of JWTs.
The Complete OAuth 2.0 + JWT Flow
In a typical OAuth 2.0 authorization code flow with JWTs, the sequence works as follows:
- The user clicks "Login" and is redirected to the authorization server (e.g., Auth0, Okta, or your own).
- The user authenticates and grants permission. The authorization server redirects back with an authorization code.
- Your server exchanges the code for an access token (JWT) and a refresh token via a back-channel request.
- The access token is sent with each API request in the
Authorization: Bearer <token>header. - Your API validates the JWT signature and claims on every request — no database lookup needed.
- When the access token expires, the client uses the refresh token to obtain a new access token.
Refresh Token Rotation
Refresh tokens are long-lived credentials that can obtain new access tokens. Because they are valuable, they need careful handling. Refresh token rotation is a security strategy where each time a refresh token is used, it is invalidated and a new one is issued. This limits the damage if a refresh token is stolen — the attacker can only use it once before it becomes invalid.
Implement refresh token rotation by storing refresh tokens in your database with a one-time-use flag. When a refresh token is presented, mark it as used and issue a new pair. If you detect a refresh token being used twice (a sign of theft), invalidate the entire token family for that user and force re-authentication.
Implementing JWT in Node.js
The jsonwebtoken library is the standard choice for JWT operations in Node.js:
const jwt = require("jsonwebtoken")
const SECRET = process.env.JWT_SECRET // min 256-bit random string
// Sign a token (on login)
const token = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
SECRET,
{ expiresIn: "15m", issuer: "https://api.example.com" }
)
// Verify a token (on each request)
try {
const payload = jwt.verify(token, SECRET, {
issuer: "https://api.example.com",
algorithms: ["HS256"] // explicitly specify algorithm
})
// payload.sub, payload.email, payload.role are now available
} catch (err) {
if (err.name === "TokenExpiredError") {
// Prompt client to refresh
} else {
// Invalid token — reject request
}
}Common JWT Vulnerabilities
⚠️ Algorithm Confusion (alg:none)
Some libraries accept tokens with 'alg: none', bypassing signature verification entirely. Always explicitly specify and whitelist allowed algorithms.
⚠️ Weak Signing Secrets
Short or predictable HMAC secrets can be brute-forced offline. Use a cryptographically random secret of at least 256 bits (32 bytes).
⚠️ Missing Claim Validation
Verifying the signature is not enough. Always validate exp (expiration), iss (issuer), and aud (audience) claims to prevent token reuse across services.
⚠️ Storing JWTs in localStorage
localStorage is accessible to any JavaScript on the page, making it vulnerable to XSS attacks. Use httpOnly, Secure, SameSite=Strict cookies instead.
Revoking JWTs Without a Blacklist
JWTs are stateless by design — the server does not store them, so there is no built-in way to revoke a specific token before it expires. The common approaches to handle revocation are:
- Short expiration times: Use 15-minute access tokens. A stolen token becomes useless quickly.
- Token version in the payload: Store a
tokenVersioncounter in your user database. Include it in the JWT. On logout or password change, increment the counter. Reject tokens with an outdated version. - Redis-based denylist: For immediate revocation, maintain a small Redis set of revoked JTI (JWT ID) values. Check this set on each request. The set only needs to hold tokens until they expire naturally.
Decode a JWT Token
Use our free JWT decoder to inspect the header, payload, and claims of any JWT token:
JWT Security Best Practices: A Comprehensive Guide
Security is not optional when working with JWTs. A misconfigured JWT implementation can give attackers full access to your system. This section consolidates every security consideration into a single actionable reference that covers validation, storage, expiration, revocation, and defense against known attacks.
Always Validate the Signature
The most critical rule: never trust a JWT payload without verifying its signature first. Decoding a JWT (Base64 decoding the payload) only reads the data — it does not confirm that the token is authentic. An attacker can craft any payload they want. Only signature verification proves the token was issued by your server and has not been tampered with.
// ❌ WRONG — only decodes, does NOT verify
const payload = JSON.parse(atob(token.split(".")[1]))
// ✅ CORRECT — verifies signature before trusting payload
const payload = jwt.verify(token, SECRET, {
algorithms: ["HS256"], // explicitly whitelist algorithms
issuer: "https://api.example.com",
audience: "https://app.example.com"
})Never Trust the Payload Without Verification
Even after signature verification, you must validate the claims inside the token. Check that the exp claim has not passed, that iss matches your expected issuer, and that aud matches your application. A valid signature only means the token was issued by someone with the key — it does not mean the token is still valid or intended for your service.
Use Short Expiration Times
Access tokens should expire quickly — typically 15 minutes for high-security applications and no more than 1 hour for standard applications. Short-lived tokens limit the damage window if a token is stolen. An attacker who captures a token can only use it until it expires. Combine short expiration with refresh token rotation for the best balance of security and user experience.
Implement Token Refresh Flows
Refresh tokens are long-lived credentials (days or weeks) stored securely on the server side. When an access token expires, the client presents the refresh token to obtain a new access token without requiring the user to log in again. Always implement refresh token rotation — each time a refresh token is used, invalidate it and issue a new one. If a refresh token is used twice, it indicates theft, and you should revoke the entire token family.
// Refresh token endpoint
app.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.body
// Check if refresh token exists and is unused
const stored = await db.findRefreshToken(refreshToken)
if (!stored || stored.used) {
// Token reuse detected — revoke entire family
await db.revokeTokenFamily(stored.familyId)
return res.status(401).json({ error: "Token reuse detected" })
}
// Mark current refresh token as used
await db.markAsUsed(refreshToken)
// Issue new token pair
const newAccessToken = jwt.sign({ sub: stored.userId }, SECRET, { expiresIn: "15m" })
const newRefreshToken = crypto.randomUUID()
await db.storeRefreshToken(newRefreshToken, stored.userId, stored.familyId)
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken })
})Store Tokens Securely: httpOnly Cookies vs localStorage
Where you store JWTs in the browser has significant security implications. localStorage is accessible to any JavaScript running on the page, making it vulnerable to XSS (Cross-Site Scripting) attacks. A single XSS vulnerability allows an attacker to steal all tokens from localStorage. httpOnly cookies, on the other hand, cannot be accessed by JavaScript at all — they are only sent automatically with HTTP requests.
| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
| localStorage | High — JS can read | None | ❌ Avoid |
| httpOnly cookie | None — JS cannot read | Possible | ✅ Use with SameSite=Strict |
| Memory (variable) | Low — lost on refresh | None | ⚠️ Poor UX (lost on reload) |
Revocation Strategies
JWTs are stateless by design, which means there is no built-in way to revoke them before expiration. However, you have several options depending on your security requirements:
- Short expiration + refresh rotation: The simplest approach. Tokens expire quickly, and refresh tokens can be revoked server-side.
- Token version (per-user): Store a version counter in your user record. Include it in the JWT. On logout or password change, increment the version. Reject tokens with an old version.
- JTI denylist in Redis: Store revoked token IDs (jti claim) in Redis with a TTL matching the token's remaining lifetime. Check the denylist on each request.
- Event-driven invalidation: Publish a revocation event when a user logs out. All services subscribe and add the JTI to their local denylist cache.
Common JWT Attacks
Understanding how attackers exploit JWTs helps you defend against them:
Algorithm Confusion Attack
The attacker changes the JWT header from RS256 (asymmetric) to HS256 (symmetric) and signs the token with the public key (which is publicly available). If the server does not enforce the expected algorithm, it may verify the signature using the public key as an HMAC secret. Always whitelist allowed algorithms explicitly.
The "none" Algorithm Attack
Some JWT libraries accept tokens with alg: "none", which means no signature is required. An attacker can forge any payload and the server will trust it. Always reject tokens with the "none" algorithm in production.
Token Replay Attack
An attacker captures a valid JWT (via network sniffing or XSS) and replays it to access protected resources. Mitigate with short expiration times, token binding (tying tokens to a specific client fingerprint), and the jti claim for one-time-use tokens.
JWT Implementation Checklist (Production-Ready)
After implementing JWT auth in dozens of projects, here's the checklist I follow every time. Miss any of these and you'll have a security audit finding:
JWT vs Alternatives: When NOT to Use JWT
JWT is fantastic for many use cases, but it's not always the right choice. Here's when you should consider alternatives:
| Scenario | Best Choice | Why |
|---|---|---|
| Simple web app, single server | Server-side sessions (cookie) | Simpler, instant revocation, no token size issues |
| Microservices architecture | JWT (short-lived) + refresh token | Stateless verification across services, no shared session store |
| Real-time revocation needed | Opaque tokens + token introspection | Server can instantly invalidate any token |
| Third-party API access | OAuth 2.0 + JWT | Delegated authorization with standard protocols |
| Extremely sensitive operations | One-time tokens (database-backed) | Each token used exactly once, immediate invalidation |
| Mobile app authentication | JWT access + rotating refresh | Works offline, handles network interruptions gracefully |
The most common mistake I see: teams use JWT for everything because they heard "JWTs are scalable" without understanding the trade-offs. If you need instant logout, frequent permission changes, or real-time revocation — server-side sessions are often simpler and more secure.
Summary
If you take one thing from this entire article, let it be this: always verify the signature. I can't tell you how many production codebases I've reviewed where someone just Base64-decodes the payload and trusts whatever's inside. That's not authentication — that's a suggestion box for attackers. Verify the signature, check the expiration, use short-lived tokens, and store them in httpOnly cookies. Everything else is details.
