X Xerobit

JWT Authentication Flow — Login, Token Storage, Refresh Tokens

JWT authentication involves issuing tokens on login, sending them with requests, and refreshing before expiry. Covers the full flow in Node.js, secure storage (httpOnly cookies...

Mian Ali Khalid · · 6 min read
Use the tool
JWT Decoder
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Open JWT Decoder →

JWT authentication requires issuing tokens on login, validating them on protected routes, refreshing before expiry, and storing them securely. Each step has security implications.

Use the JWT Decoder to inspect any JWT token’s header, payload, and signature.

The complete JWT auth flow

1. User submits credentials (POST /auth/login)
2. Server validates credentials
3. Server issues: access token (short-lived) + refresh token (long-lived)
4. Client stores tokens securely
5. Client sends access token with every request
6. Server validates access token on protected routes
7. When access token expires → use refresh token to get new one
8. Logout: revoke/delete refresh token

Issue tokens on login (Node.js)

import jwt from 'jsonwebtoken';
import crypto from 'crypto';

const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET;

function issueAccessToken(userId, role) {
  return jwt.sign(
    { sub: userId, role },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m', issuer: 'api.yourapp.com' }
  );
}

function issueRefreshToken(userId) {
  return jwt.sign(
    { sub: userId },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
}

app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);
  
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  const accessToken = issueAccessToken(user.id, user.role);
  const refreshToken = issueRefreshToken(user.id);
  
  // Store refresh token hash in DB (for revocation):
  const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
  await db.refreshTokens.create({ userId: user.id, tokenHash, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) });
  
  // Refresh token in httpOnly cookie:
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,        // HTTPS only
    sameSite: 'strict',  // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000,  // 7 days
  });
  
  // Access token in response body (client stores in memory):
  res.json({ accessToken });
});

Validate tokens on protected routes

function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }
  
  const token = authHeader.slice(7);
  
  try {
    const payload = jwt.verify(token, ACCESS_TOKEN_SECRET, {
      issuer: 'api.yourapp.com',
    });
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'TOKEN_EXPIRED', message: 'Access token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Usage:
app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ userId: req.user.id });
});

Refresh token endpoint

app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }
  
  let payload;
  try {
    payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
  
  // Verify token exists in DB (not revoked):
  const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
  const stored = await db.refreshTokens.findOne({ tokenHash });
  
  if (!stored || stored.expiresAt < new Date()) {
    return res.status(401).json({ error: 'Refresh token revoked or expired' });
  }
  
  // Refresh token rotation — issue new refresh token, revoke old:
  await db.refreshTokens.delete({ tokenHash });
  
  const newAccessToken = issueAccessToken(payload.sub);
  const newRefreshToken = issueRefreshToken(payload.sub);
  const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
  
  await db.refreshTokens.create({ userId: payload.sub, tokenHash: newHash });
  
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
  
  res.json({ accessToken: newAccessToken });
});

Logout

app.post('/auth/logout', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  
  if (refreshToken) {
    const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
    await db.refreshTokens.delete({ tokenHash });
  }
  
  res.clearCookie('refreshToken');
  res.json({ message: 'Logged out' });
});

Client-side token management

// Store access token in memory (NOT localStorage — XSS vulnerable):
let accessToken = null;

async function login(email, password) {
  const res = await fetch('/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
    credentials: 'include',  // Send/receive cookies
  });
  const data = await res.json();
  accessToken = data.accessToken;
}

async function apiFetch(url, options = {}) {
  // If token expired, refresh first:
  if (isTokenExpired(accessToken)) {
    await refreshTokens();
  }
  
  return fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
    credentials: 'include',
  });
}

async function refreshTokens() {
  const res = await fetch('/auth/refresh', {
    method: 'POST',
    credentials: 'include',
  });
  if (!res.ok) {
    accessToken = null;
    redirectToLogin();
    return;
  }
  const data = await res.json();
  accessToken = data.accessToken;
}

function isTokenExpired(token) {
  if (!token) return true;
  const { exp } = JSON.parse(atob(token.split('.')[1]));
  return exp * 1000 < Date.now() + 30000;  // 30s buffer
}

Related posts

Related tool

JWT Decoder

Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.

Written by Mian Ali Khalid. Part of the Dev Productivity pillar.