Introduction
Authentication is the front door to almost every application you'll ever build. Get it wrong, and it doesn't matter how polished your UI is or how clever your business logic is — attackers will walk right through that door. Get it right, and your users trust you with their data, their money, and their identity.
In this guide, we're going to build a complete, production-grade authentication flow from scratch using three pillars that, together, form the backbone of modern secure auth systems in 2026:
- JSON Web Tokens (JWT) for stateless, verifiable session identity
- Refresh tokens for long-lived sessions without compromising security
- Rate limiting to defend against brute-force and credential-stuffing attacks
By the end of this article, you'll understand not just how to implement these pieces, but why each design decision matters — including the mistakes that even experienced engineers make when rolling out auth systems.
This is a beginner-friendly walkthrough, but it's written with enough depth that intermediate developers building real production systems will find practical, battle-tested patterns here too.
Why Authentication Design Matters More Than Ever
Authentication has gotten more complex over the years, not less. A few reasons:
- Mobile and SPA clients need token-based auth instead of traditional server-rendered sessions.
- Microservices architectures need stateless verification — you can't check a session table on every service.
- Credential stuffing attacks are now automated at massive scale using leaked password databases.
- Compliance requirements (SOC 2, GDPR, HIPAA) increasingly mandate specific session and token handling practices.
A poorly designed auth system is one of the most common root causes of major breaches. This isn't theoretical — misconfigured JWTs, tokens that never expire, and unprotected login endpoints show up again and again in security incident reports.
Let's fix that, piece by piece.
Core Concepts Explained
What Is a JWT, Really?
A JSON Web Token is a compact, URL-safe string composed of three Base64Url-encoded parts separated by dots:
header.payload.signature
- Header — specifies the algorithm (e.g.,
HS256orRS256) and token type. - Payload — contains claims: user ID, roles, issued-at time, expiry, etc.
- Signature — a cryptographic signature that proves the token hasn't been tampered with.
The critical thing to understand: a JWT is signed, not encrypted (unless you specifically use JWE). Anyone can decode the payload and read it. Never put secrets, passwords, or sensitive PII inside a JWT payload.
Access Tokens vs. Refresh Tokens
This is the distinction that trips up most newcomers:
| Token Type | Lifespan | Purpose | Where Stored |
|---|---|---|---|
| Access Token | Short (5–15 min) | Authorizes API requests | Memory or short-lived cookie |
| Refresh Token | Long (7–30 days) | Issues new access tokens | httpOnly, secure cookie or DB-backed store |
The access token is what your API checks on every request. Because it's short-lived, even if it leaks, the damage window is small.
The refresh token exists purely to get a new access token once the old one expires — without forcing the user to log in again every 10 minutes. Because it's long-lived and powerful, it needs much stronger protection.
Why Not Just Use One Long-Lived Token?
This is the single biggest mistake in DIY auth systems. If you issue one JWT valid for 30 days and use it directly for every API call:
- A stolen token is valid for the entire 30-day window.
- You can't easily revoke it (JWTs are stateless by design).
- You're stuck either accepting that risk or building a token-blacklist system, which defeats the purpose of using stateless JWTs in the first place.
The access/refresh split solves this elegantly: short exposure window for the powerful token, and a revocable, rotatable long-lived token that's rarely transmitted.
Building the Auth Flow
Let's build this in Node.js with Express, jsonwebtoken, bcrypt, and express-rate-limit. The same principles apply to any stack — Django, Spring Boot, Laravel, or Go — only the syntax changes.
Project Setup
mkdir secure-auth-demo && cd secure-auth-demo
npm init -y
npm install express jsonwebtoken bcrypt express-rate-limit cookie-parser dotenv
npm install --save-dev nodemon
Create a .env file — never hardcode secrets:
ACCESS_TOKEN_SECRET=replace_with_a_long_random_string
REFRESH_TOKEN_SECRET=replace_with_a_different_long_random_string
PORT=4000
Generate strong secrets with:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Step 1: User Registration
Passwords must never be stored in plain text. We use bcrypt, which incorporates salting automatically.
// routes/auth.js
const bcrypt = require('bcrypt');
const express = require('express');
const router = express.Router();
// In-memory store for demo purposes — use a real DB in production
const users = [];
router.post('/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: 'Invalid email or password too short' });
}
const existingUser = users.find((u) => u.email === email);
if (existingUser) {
return res.status(409).json({ error: 'User already exists' });
}
const hashedPassword = await bcrypt.hash(password, 12); // cost factor of 12
const newUser = { id: Date.now().toString(), email, password: hashedPassword };
users.push(newUser);
res.status(201).json({ message: 'User registered successfully' });
});
module.exports = { router, users };
Note: A bcrypt cost factor of 12 is a reasonable 2026 default — it balances security against server load. Adjust based on your hardware and load-testing results.
Step 2: Login and Token Issuance
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
let refreshTokenStore = new Map(); // token -> userId (use Redis in production)
function generateAccessToken(user) {
return jwt.sign({ sub: user.id, email: user.email }, ACCESS_TOKEN_SECRET, {
expiresIn: '10m',
});
}
function generateRefreshToken(user) {
const token = jwt.sign({ sub: user.id }, REFRESH_TOKEN_SECRET, {
expiresIn: '7d',
});
refreshTokenStore.set(token, user.id);
return token;
}
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // requires HTTPS
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
Notice the pattern:
- Access token → returned in the JSON body, held in memory on the client (not localStorage — more on that below).
- Refresh token → set as an
httpOnly,secure,sameSite=strictcookie, invisible to JavaScript entirely.
This split is deliberate. It protects the refresh token from XSS attacks while keeping the access token easily usable by your frontend's HTTP client.
Step 3: Protecting Routes with Middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer <token>"
if (!token) return res.status(401).json({ error: 'Access token required' });
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
if (err) return res.status(403).json({ error: 'Invalid or expired token' });
req.user = decoded;
next();
});
}
router.get('/profile', authenticateToken, (req, res) => {
res.json({ message: `Hello, ${req.user.email}` });
});
Every protected route just adds authenticateToken as middleware. Clean, composable, and stateless — no database lookup required on every request.
Step 4: Refreshing Tokens (with Rotation)
This is where most tutorials cut corners. We're going to do it properly with refresh token rotation: every time a refresh token is used, it's invalidated and replaced with a new one. If a stolen refresh token is ever reused after the legitimate one rotates, we detect it and revoke the entire token family.
router.post('/refresh', (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ error: 'Refresh token missing' });
if (!refreshTokenStore.has(token)) {
// Possible token reuse/theft — revoke all sessions for safety
return res.status(403).json({ error: 'Refresh token invalid or already used' });
}
jwt.verify(token, REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
refreshTokenStore.delete(token);
return res.status(403).json({ error: 'Refresh token expired' });
}
// Rotate: invalidate old token, issue a new one
refreshTokenStore.delete(token);
const user = users.find((u) => u.id === decoded.sub);
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: newAccessToken });
});
});
router.post('/logout', (req, res) => {
const token = req.cookies.refreshToken;
if (token) refreshTokenStore.delete(token);
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
In production, replace the in-memory Map with Redis, storing refresh tokens with a TTL matching their expiry. This gives you fast lookups, automatic expiration, and the ability to revoke sessions instantly (e.g., "log out all devices").
Adding Rate Limiting
Auth endpoints are the single most targeted surface in any application. Without rate limiting, your /login endpoint is an open invitation for credential-stuffing bots.
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 login attempts per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Please try again later.' },
});
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
message: { error: 'Too many accounts created from this IP. Try again later.' },
});
router.post('/login', loginLimiter, /* ...existing handler */);
router.post('/register', registerLimiter, /* ...existing handler */);
Going Beyond Basic IP Rate Limiting
IP-based limiting alone isn't enough in 2026 — attackers rotate IPs using residential proxy networks. Layer in:
- Per-account limiting: track failed attempts per email/username, independent of IP.
- Progressive delays: exponentially increase lockout duration after repeated failures.
- CAPTCHA challenges: trigger after 3 failed attempts rather than blocking outright.
- Distributed rate limiting: use Redis-backed rate limiters (e.g.,
rate-limiter-flexible) so limits are enforced consistently across multiple server instances.
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('./redisClient');
const perAccountLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_fail_by_email',
points: 5, // 5 attempts
duration: 900, // per 15 minutes
blockDuration: 900, // block for 15 minutes after limit hit
});
Real-World Example: Putting It All Together
Imagine a SaaS dashboard app. Here's how the full flow plays out for a user:
- Registration — User signs up; password is hashed with bcrypt and stored. Rate limiting caps signups per IP to prevent bulk fake-account creation.
- Login — Credentials verified; a 10-minute access token and a 7-day refresh token are issued. The refresh token goes into an
httpOnlycookie; the access token goes to the client's in-memory store (e.g., a React context or Redux store — never localStorage). - Using the app — Every API call attaches
Authorization: Bearer <accessToken>. The middleware verifies it stateless-ly, with zero database hits. - Token expiry — After 10 minutes, the access token expires. The frontend's HTTP client (e.g., an Axios interceptor) catches the
401, silently calls/refresh, receives a new access token, and retries the original request — the user never notices. - Refresh token rotation — Each refresh issues a new refresh token and invalidates the old one, closing the window for replay attacks.
- Logout — The refresh token is deleted server-side and the cookie is cleared. The access token, being short-lived, naturally expires shortly after even if somehow retained.
Here's a simplified Axios interceptor for step 4:
import axios from 'axios';
let accessToken = null; // in-memory only
const api = axios.create({ baseURL: '/api', withCredentials: true });
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
accessToken = data.accessToken;
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
return api(originalRequest);
}
return Promise.reject(error);
}
);
Best Practices
- Always use HTTPS in production. Cookies marked
secureare useless over plain HTTP, and tokens sent over unencrypted channels can be intercepted. - Keep access tokens short-lived (5–15 minutes). The shorter the lifespan, the smaller the blast radius if one leaks.
- Store refresh tokens in httpOnly cookies, never in localStorage or sessionStorage, where they're exposed to any injected script.
- Rotate refresh tokens on every use, and detect reuse of an already-rotated token as a signal of possible theft — revoke the whole session family when detected.
- Hash passwords with bcrypt or argon2, never MD5, SHA-1, or plain SHA-256 without a proper KDF.
- Rate limit registration, login, and password reset endpoints — these are the highest-value targets for automated attacks.
- Use strong, unique secrets for signing access and refresh tokens — never reuse the same secret for both.
- Validate and sanitize all inputs server-side, even if you also validate on the client.
- Set
sameSite=strictorlaxon cookies to mitigate CSRF risk. - Log authentication events (failed logins, token refresh failures, logouts) for auditing and anomaly detection.
- Consider short-lived JWTs plus a revocation list for extra-sensitive actions (e.g., wire transfers, admin actions) rather than relying purely on token expiry.
Common Mistakes to Avoid
- Storing JWTs in localStorage. It feels convenient, but it's directly readable by any JavaScript running on the page — including injected malicious scripts via XSS.
- Using a single long-lived token for everything. This removes your ability to limit exposure or revoke access gracefully.
- Forgetting to rotate refresh tokens. A refresh token that never changes is just as dangerous as a session that never expires.
- Skipping rate limiting "for now." Attackers don't wait for you to get around to it — unprotected login endpoints get hit within hours of going live on the public internet.
- Putting sensitive data in the JWT payload. Remember: JWTs are signed, not encrypted. Anyone can Base64-decode and read the payload.
- Not handling clock skew. If your servers' clocks drift, token expiry checks can behave unpredictably — use NTP syncing and small leeway windows (
clockToleranceinjsonwebtoken). - Relying only on client-side validation. Client-side checks are a UX nicety, not a security boundary.
- Ignoring token revocation on logout. If your refresh tokens live in a database or Redis, make sure logout actually deletes them — don't just clear the cookie client-side.
- Using weak or predictable JWT secrets. A short or guessable secret undermines the entire signature scheme.
🚀 Pro Tips
- Use
argon2idinstead of bcrypt if you want the more modern, memory-hard password hashing algorithm — it's more resistant to GPU-based cracking attempts. - Implement device/session tracking: store metadata (IP, user agent, timestamp) with each refresh token so users can see and revoke "active sessions" from their account settings.
- For microservices, consider asymmetric signing (RS256) for access tokens — the auth service holds the private key, while other services only need the public key to verify tokens, without ever being able to issue new ones.
- Add a "reuse detection" alert system: if a rotated-out refresh token is presented again, treat it as a security incident and force a full logout across all devices, then notify the user by email.
- Pair rate limiting with structured logging and alerting (e.g., via a SIEM tool) so spikes in failed logins trigger a real-time alert, not just a silently dropped request.
- Consider WebAuthn/passkeys as a complementary or alternative first factor — by 2026, passwordless flows significantly reduce credential-stuffing risk entirely.
📌 Key Takeaways
- JWTs are signed, not encrypted — never store sensitive data in the payload.
- Use short-lived access tokens paired with long-lived, rotating refresh tokens to balance usability and security.
- Store refresh tokens in
httpOnly,secure,sameSitecookies — keep access tokens out of localStorage. - Rate limit every authentication endpoint, and layer in per-account and progressive-delay strategies beyond simple IP limits.
- Detect and respond to refresh token reuse as a signal of potential compromise.
- Treat authentication as a system of layered defenses, not a single mechanism.
Conclusion
Building a secure authentication system isn't about finding one clever trick — it's about layering multiple well-understood defenses so that even if one layer fails, the others hold the line. JWTs give you fast, stateless verification. Refresh tokens with rotation give you long sessions without long-lived risk. Rate limiting keeps automated attackers from ever getting a foothold in the first place.
The patterns in this guide — short-lived access tokens, httpOnly refresh token cookies, rotation with reuse detection, and layered rate limiting — reflect what's considered solid, production-grade practice going into 2026. They're not exotic; they're the baseline that any application handling real user data should meet.
Start simple, but start secure. Retrofitting security into an auth system after a breach is far more expensive — in trust and in engineering time — than building it correctly from day one.