JWT Authentication Done Right in Node.js
I walk through JWT structure, signing tokens with jsonwebtoken, writing a verify middleware for Express, the refresh token pattern, and where to store tokens safely.

Here’s something that trips up most developers the first time they implement JWT authentication: the payload of a token is not encrypted. Anyone who intercepts the token — or just decodes it in a browser tab — can read every claim in plain text. The signature only proves the server issued it; it doesn’t hide the contents.
That distinction matters a lot for what you put in a token, where you store it, and what your middleware actually needs to check. Get those three things right and JWT authentication holds up well. Get any of them wrong quietly and it still “works” — right up until it doesn’t.
Quick answer: Install
jsonwebtoken, calljwt.sign({ sub: userId }, secret, { expiresIn: "15m" })after login, verify withjwt.verify(token, secret)in a middleware before each protected route, and issue a separate long-lived refresh token so the short access token can expire without logging the user out. Never store access tokens inlocalStorage.
On this page
- What a JWT actually contains
- Issuing tokens after a successful login
- Protecting routes with a verify middleware
- Refresh tokens and why access tokens expire fast
- Where to store tokens on the client
- Mistakes that silently break JWT authentication
- Treat the token as a signed claim, not a session
What a JWT actually contains
A JWT (defined in RFC 7519) is three Base64url-encoded JSON objects joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc0NjUxMjAwMH0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The three parts are:
- Header — describes the token type and signing algorithm. Decoded:
{ "alg": "HS256", "typ": "JWT" }. - Payload — the claims. Decoded:
{ "sub": "user_123", "role": "admin", "exp": 1746512000 }. - Signature —
HMAC-SHA256(base64url(header) + "." + base64url(payload), secret).
The signature is the only part that requires a secret. The header and payload are just Base64url — not encrypted, not obscured, readable by anyone. You can paste a JWT into jwt.io and read the payload immediately without knowing the secret.
This means: don’t store anything sensitive in the payload. User IDs, roles, and expiry times are fine. Passwords, credit card numbers, and private keys are not.
The registered claim names are short by design — iss (issuer), sub (subject), aud (audience), exp (expiration), nbf (not before), iat (issued at), and jti (JWT ID). RFC 7519 Section 4.1 lists all of them. Use them instead of inventing your own equivalents, because libraries and downstream services know how to handle the standard ones.
Issuing tokens after a successful login
Install the library:
npm install jsonwebtoken
After verifying the user’s credentials, sign and return two tokens — an access token and a refresh token (covered in the next section):
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
function issueTokens(userId) {
const accessToken = jwt.sign(
{ sub: userId },
ACCESS_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ sub: userId },
REFRESH_SECRET,
{ expiresIn: "7d" }
);
return { accessToken, refreshToken };
}
A few things worth calling out here.
sub is the standard claim for the subject — the entity the token is about. Using sub for the user ID keeps the payload portable. jwt.sign automatically adds iat (issued at) to the payload.
The expiresIn option sets the exp claim. The value "15m" is a string understood by the ms library that jsonwebtoken uses internally. You can also pass a number of seconds: { expiresIn: 900 }. The access token is intentionally short. The refresh token is long.
Your secrets must have high entropy. RFC 8725 Section 3.5 is explicit: human-memorizable passwords must not be used as HMAC keys. Generate them with a cryptographic random source:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Store them in environment variables, never in source files. If you’re building the broader Express structure that hosts this auth logic, the production Express project structure guide has a good place to keep secrets and config separate from route code.
Protecting routes with a verify middleware
A middleware function that runs before every protected route is the cleanest way to centralize token verification:
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
export function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "missing token" });
}
const token = authHeader.slice(7); // remove "Bearer "
try {
const payload = jwt.verify(token, ACCESS_SECRET, {
algorithms: ["HS256"],
});
req.user = payload;
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ error: "token expired" });
}
return res.status(401).json({ error: "invalid token" });
}
}
The algorithms option in jwt.verify is important. Without it, the library uses a default set based on the key type. Specifying it explicitly means a token that claims alg: "none" will be rejected outright — that’s the right behaviour. RFC 8725 Section 3.1 describes this class of attack: an attacker changes the algorithm to "none" and strips the signature, and some libraries would accept it.
jwt.verify throws a TokenExpiredError when the exp claim is in the past, and a JsonWebTokenError for anything else — malformed token, wrong signature, algorithm mismatch. Distinguishing the two lets the client know whether to refresh or to re-authenticate from scratch.
To protect a route, register the middleware before the handler:
app.get("/api/account", requireAuth, (req, res) => {
res.json({ userId: req.user.sub });
});
If you want TypeScript types on req.user, extend the Request interface. The TypeScript for JavaScript Developers post covers how module augmentation works if that pattern is unfamiliar.
Check this before moving on
-
jwt.verifyis called with thealgorithmsoption explicitly set - Token expiry and invalid-signature errors are handled separately
- The
Authorizationheader is checked before callingjwt.verify - The secret is loaded from an environment variable, not hard-coded
Refresh tokens and why access tokens expire fast
A 15-minute access token is useless the moment the user navigates to a new page — unless you give them a way to get a new one without logging in again. That’s what the refresh token does.
The refresh token is a long-lived credential (days or weeks) stored securely. When the access token expires, the client sends the refresh token to a dedicated /auth/refresh endpoint. The server verifies the refresh token, checks it hasn’t been revoked, and issues a new access token.
app.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: "missing refresh token" });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
algorithms: ["HS256"],
});
// Verify the refresh token is still valid in your store
// (e.g. check a database table of issued refresh tokens)
const isValid = await refreshTokenStore.exists(refreshToken);
if (!isValid) {
return res.status(401).json({ error: "refresh token revoked" });
}
const newAccessToken = jwt.sign(
{ sub: payload.sub },
ACCESS_SECRET,
{ expiresIn: "15m" }
);
res.json({ accessToken: newAccessToken });
} catch {
res.status(401).json({ error: "invalid refresh token" });
}
});
The refreshTokenStore.exists check is the part most implementations skip on the first pass. Without it, a stolen refresh token is valid for its entire lifetime even after the user logs out. The store can be a database table or a Redis set — anything that persists the token ID and lets you delete it on logout or password change. If you build the store into a proper Express project, the REST API with Node.js and Express post shows where a data-access layer fits in a route file.
Where to store tokens on the client
This question has a clearer answer than most debates suggest.
Access token in memory is the most secure option. The token lives in a JavaScript variable, never written to disk or storage. An XSS attack can’t read it from localStorage because it’s not there. The downside: the token is gone on page refresh, which means the client needs to use the refresh token immediately to get a new one.
Refresh token in an httpOnly cookie is the standard complement to in-memory access tokens. An httpOnly cookie can’t be read by JavaScript, which means XSS attacks can’t steal it. Set Secure and SameSite=Strict (or Lax for cross-origin flows) to reduce the risk further. The server sets the cookie on login; the client never touches it directly.
Access token in localStorage is convenient but introduces real risk. OWASP’s HTML5 Security Cheat Sheet makes the point plainly: a single XSS vulnerability exposes everything in localStorage. Session identifiers belong in httpOnly cookies, not in localStorage.
The practical pattern for most applications: access token in memory, refresh token in an httpOnly cookie. On page load, immediately call /auth/refresh with the cookie to get a fresh access token, then hold it in memory until it expires.
Mistakes that silently break JWT authentication
The most common mistakes don’t throw errors immediately — they just leave the system open:
Not specifying algorithms in jwt.verify. The library’s default behavior is reasonable, but explicitly listing the expected algorithm closes the algorithm-confusion attack described in RFC 8725 Section 2.1. It’s one option and takes ten seconds to add.
Using a weak or guessable secret. An HMAC-signed JWT with a short secret can be brute-forced offline once an attacker captures any token. The attacker doesn’t need to talk to your server. A 256-bit randomly generated secret makes this attack infeasible in practice.
Never revoking refresh tokens. JWTs are stateless, which means an access token can’t be individually revoked before it expires. That’s why access tokens should be short. Refresh tokens, on the other hand, live in your store and should be deleted immediately on logout, password change, or suspicious activity.
Putting sensitive data in the payload. I mentioned this at the top, but it’s worth repeating. The payload is readable. If you include a permission that the user can read and replay, you’ve created an information leak. Keep payloads small and boring: subject, role, expiry.
Not validating exp manually when you decode without verifying. jwt.decode skips signature verification entirely — it’s for debugging, not for trust decisions. If you call jwt.decode and then check payload.exp by hand, you’re doing extra work that jwt.verify already does, and you’re bypassing the signature check. Always use jwt.verify for tokens you intend to trust.
Treat the token as a signed claim, not a session
The mental model shift that makes JWT auth click: a JWT is a bearer credential with an expiry baked in. The server doesn’t look it up in a database on each request — it verifies the signature and trusts the claims. That’s both the power and the responsibility.
The power: no database query per request, no shared session store, easy to scale horizontally. The responsibility: the server can’t easily revoke an access token mid-flight, which is exactly why you keep them short and pair them with a refresh token pattern you can revoke.
If you’re building authentication into a Next.js app, Authentication in Next.js: A Practical Architecture covers how this same token pattern fits into the App Router and how server components handle token forwarding.
Start with the code here, test the middleware with a few invalid tokens before wiring it to real routes, and make sure your secrets are randomly generated and stored outside your source tree.

A JWT is three Base64url-encoded sections joined by dots. Only the signature requires the secret; the header and payload are readable by anyone.