March 26, 202611 min read

Authentication with JWTs: How It Actually Works and Where People Mess Up

A practical guide to JWT-based authentication. Covers how JWTs work, access and refresh tokens, where to store them, common security mistakes, and when JWTs aren't the right choice.

authentication jwt security backend
Ad 336x280

Authentication is one of those things that seems simple until you actually implement it. "Just check if the user is logged in" -- sure, but how? How does the server know who's making a request? How do you keep someone logged in across page reloads? How do you handle expiration without making the user log in every 15 minutes?

JWTs (JSON Web Tokens) are the most common answer to these questions in modern web applications. They're not the only answer, and they're frequently misused, but when applied correctly, they solve a real problem cleanly. Let's understand what that problem is and how JWTs address it.

The Problem: Stateless Authentication

Traditional session-based auth works like this: you log in, the server creates a session (stored in a database or memory), gives you a session ID cookie, and on every request, the server looks up your session by that ID.

This works fine until you have multiple servers. Server A creates the session, but the next request goes to Server B, which has no idea who you are. You can solve this with a shared session store (like Redis), but that's another piece of infrastructure to manage and a potential bottleneck.

JWTs take a different approach: instead of the server storing your session, it gives you a token that contains your identity and permissions. The token is cryptographically signed, so the server can verify it's legitimate without looking anything up. No shared state needed.

What's Inside a JWT

A JWT is three Base64-encoded JSON objects separated by dots:

header.payload.signature
Header -- metadata about the token:
{
  "alg": "HS256",
  "typ": "JWT"
}
Payload -- the actual data (called "claims"):
{
  "sub": "user_12345",
  "name": "Jane Doe",
  "role": "admin",
  "iat": 1711411200,
  "exp": 1711414800
}
Signature -- a cryptographic signature that proves the header and payload haven't been tampered with.

Important: the payload is encoded, not encrypted. Anyone can decode a JWT and read its contents. Never put secrets, passwords, or sensitive personal data in a JWT. The signature only guarantees the data hasn't been modified -- it doesn't hide it.

You can paste any JWT into jwt.io and see its contents immediately.

The Authentication Flow

Here's how JWT auth typically works:

1. Client sends credentials (email + password) to POST /login
  1. Server verifies credentials against the database
  2. Server creates a JWT containing the user's ID and signs it
  3. Server sends the JWT back to the client
  4. Client stores the JWT
  5. Client sends the JWT in the Authorization header on every request
  6. Server verifies the JWT signature and extracts the user info
No session lookup on step 7. The server just verifies the signature mathematically and trusts the payload. That's the stateless part.

Implementing JWT Auth (Node.js)

npm install jsonwebtoken bcrypt
Registration and login:
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";

const JWT_SECRET = process.env.JWT_SECRET; // Keep this secret!
const ACCESS_TOKEN_EXPIRY = "15m";
const REFRESH_TOKEN_EXPIRY = "7d";

// Registration
app.post("/register", async (req, res) => {
const { email, password } = req.body;

// Hash the password (NEVER store plain text)
const hashedPassword = await bcrypt.hash(password, 12);

// Save user to database
const user = await db.createUser({ email, password: hashedPassword });

res.status(201).json({ message: "User created" });
});

// Login
app.post("/login", async (req, res) => {
const { email, password } = req.body;

const user = await db.findUserByEmail(email);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}

const passwordMatch = await bcrypt.compare(password, user.password);
if (!passwordMatch) {
return res.status(401).json({ error: "Invalid credentials" });
}

// Create tokens
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);

const refreshToken = jwt.sign(
{ sub: user.id, type: "refresh" },
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);

res.json({ accessToken, refreshToken });
});

Protecting routes with middleware:
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}

const token = authHeader.split(" ")[1];

try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = payload; // { sub: "user_12345", role: "admin" }
next();
} catch (error) {
if (error.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
return res.status(401).json({ error: "Invalid token" });
}
}

// Usage
app.get("/profile", authenticate, (req, res) => {
// req.user is available here
res.json({ userId: req.user.sub, role: req.user.role });
});

Role-based authorization:
function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: "Insufficient permissions" });
    }
    next();
  };
}

// Only admins can access this
app.delete("/users/:id", authenticate, authorize("admin"), (req, res) => {
// Delete user logic
});

Access Tokens and Refresh Tokens

A single JWT that lasts forever is a security disaster -- if it's stolen, the attacker has permanent access. But a JWT that expires every 15 minutes forces users to log in constantly.

The solution is two tokens:

Access token -- short-lived (15 minutes). Used for API requests. If stolen, the damage is limited by its short lifespan. Refresh token -- long-lived (7 days to 30 days). Used only to get new access tokens. Stored more securely than the access token.

The flow:

1. User logs in → gets access token (15 min) + refresh token (7 days)
  1. User makes API requests with the access token
  2. Access token expires
  3. Client sends refresh token to POST /refresh
  4. Server verifies refresh token, issues new access token
  5. Repeat from step 2
app.post("/refresh", (req, res) => {
  const { refreshToken } = req.body;

try {
const payload = jwt.verify(refreshToken, JWT_SECRET);

if (payload.type !== "refresh") {
return res.status(401).json({ error: "Invalid token type" });
}

// Issue new access token
const accessToken = jwt.sign(
{ sub: payload.sub, role: payload.role },
JWT_SECRET,
{ expiresIn: "15m" }
);

res.json({ accessToken });
} catch (error) {
return res.status(401).json({ error: "Invalid refresh token" });
}
});

On the client side, intercept 401 responses and automatically refresh:

async function fetchWithAuth(url, options = {}) {
  let accessToken = getStoredAccessToken();

let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: Bearer ${accessToken},
},
});

if (response.status === 401) {
// Try refreshing the token
const newTokens = await refreshAccessToken();
if (newTokens) {
accessToken = newTokens.accessToken;
storeAccessToken(accessToken);

// Retry the original request
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: Bearer ${accessToken},
},
});
} else {
// Refresh failed, redirect to login
redirectToLogin();
}
}

return response;
}

Where to Store Tokens

This is where many developers make mistakes.

localStorage -- accessible via JavaScript. Convenient, but vulnerable to XSS (cross-site scripting) attacks. If an attacker injects JavaScript into your page, they can steal the token. sessionStorage -- same as localStorage but clears when the tab closes. Same XSS vulnerability. Also means the user logs out when they close the tab. httpOnly cookies -- set by the server, inaccessible to JavaScript. This is the most secure option for web apps. The browser automatically sends the cookie with every request, and XSS attacks can't read it.
// Server: set token in httpOnly cookie
res.cookie("accessToken", accessToken, {
  httpOnly: true,    // JavaScript can't access this
  secure: true,      // Only sent over HTTPS
  sameSite: "strict", // Not sent with cross-site requests
  maxAge: 15  60  1000, // 15 minutes
});
The practical recommendation: use httpOnly cookies for web applications. Use localStorage only if you're building a mobile app or desktop app where XSS isn't a concern, or if your API serves multiple domains that can't share cookies.

Implementing JWT Auth (Python)

Using PyJWT:

import jwt
import bcrypt
from datetime import datetime, timedelta, timezone
from functools import wraps
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET_KEY = os.environ["JWT_SECRET"]

def create_access_token(user_id, role):
payload = {
"sub": user_id,
"role": role,
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

def require_auth(f):
@wraps(f)
def decorated(args, *kwargs):
auth_header = request.headers.get("Authorization", "")

if not auth_header.startswith("Bearer "):
return jsonify({"error": "No token provided"}), 401

token = auth_header.split(" ")[1]

try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
request.user = payload
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401

return f(args, *kwargs)
return decorated

@app.route("/profile")
@require_auth
def profile():
return jsonify({"user_id": request.user["sub"]})

Token Revocation: The Hard Part

Here's the tradeoff with JWTs: since they're stateless, you can't revoke them. If you issue a 15-minute access token and the user gets hacked, that token is valid for 15 minutes no matter what. You can't "log them out" server-side.

Solutions (each with tradeoffs):

Short expiry times. Keep access tokens very short-lived (15 minutes or less). Even if one is stolen, the window of vulnerability is small. Token blocklist. Maintain a list of revoked token IDs (the jti claim). Check this list on every request. This re-introduces server-side state, which partially defeats the purpose of JWTs. Refresh token rotation. Each time a refresh token is used, issue a new refresh token and invalidate the old one. If a stolen refresh token is used after the legitimate user already refreshed, you'll see the reuse and can invalidate all tokens for that user.
app.post("/refresh", async (req, res) => {
  const { refreshToken } = req.body;

try {
const payload = jwt.verify(refreshToken, JWT_SECRET);

// Check if this refresh token has been used before
const isUsed = await db.isRefreshTokenUsed(refreshToken);
if (isUsed) {
// Possible token theft -- invalidate all tokens for this user
await db.revokeAllRefreshTokens(payload.sub);
return res.status(401).json({ error: "Token reuse detected" });
}

// Mark current refresh token as used
await db.markRefreshTokenUsed(refreshToken);

// Issue new tokens
const newAccessToken = createAccessToken(payload.sub);
const newRefreshToken = createRefreshToken(payload.sub);
await db.storeRefreshToken(newRefreshToken, payload.sub);

res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
} catch (error) {
return res.status(401).json({ error: "Invalid refresh token" });
}
});

HS256 vs RS256

HS256 (HMAC) uses a shared secret. The same key signs and verifies. Simple, but every service that needs to verify tokens must have the secret. RS256 (RSA) uses a public/private key pair. The private key signs tokens; the public key verifies them. Any service can verify without knowing the signing key.

Use RS256 when:


  • Multiple services need to verify tokens (microservices architecture)

  • You want to separate token creation (auth service) from verification (all other services)

  • You're building a public API where third parties verify tokens


Use HS256 when:

  • You have a single server or a small, trusted set of servers

  • Simplicity is more important than key distribution


Common Mistakes

Storing sensitive data in the payload. JWTs are encoded, not encrypted. Don't put passwords, SSNs, or credit card numbers in there. Keep the payload minimal: user ID, role, expiration. Not validating the algorithm. Some libraries accept the alg from the token header by default. An attacker could set alg: "none" and skip the signature entirely. Always specify the expected algorithm explicitly:
jwt.verify(token, secret, { algorithms: ["HS256"] }); // Explicit!
Using a weak secret. Your JWT secret should be a long, random string -- at least 256 bits. Not "secret", not "my-app-secret", not your company name.
# Generate a good secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Not checking expiration. Most JWT libraries check exp automatically, but verify that yours does. An expired token should always be rejected. Putting JWTs in URL parameters. URLs get logged in server logs, browser history, and proxy caches. Never put tokens in URLs. Not using HTTPS. JWTs sent over plain HTTP can be intercepted by anyone on the network. Always use HTTPS in production.

When JWTs Aren't the Answer

JWTs aren't always the best choice:

Simple server-rendered apps. If you have a single server rendering HTML, session cookies are simpler and more secure. You don't need the stateless benefits of JWTs. When you need instant revocation. If "log out means immediately logged out everywhere" is a hard requirement, session-based auth (where deleting the session instantly revokes access) is simpler than maintaining a JWT blocklist. When tokens get too big. JWTs are sent with every request. If you stuff them with too many claims, you're adding kilobytes of overhead to every API call. Keep them lean.

The honest answer is that for many web applications, server-side sessions with a Redis store are simpler and more secure than JWTs. JWTs shine in distributed systems, microservices, and APIs consumed by mobile apps -- contexts where statelessness has real value.

If you're learning backend development and want to practice building authentication from scratch, CodeUp helps you develop the foundational skills you need to implement secure, production-ready auth systems.

Ad 728x90