Authentication45-60 min
JWT Implementation
This guide shows a practical JWT setup with short-lived access tokens, longer-lived refresh tokens, server-side validation, route protection, logout, and common security checks.
Node.jsExpressReactJWT
Prerequisites
- A Node.js API with Express or a similar HTTP framework.
- A users table with unique email and hashed password fields.
- A frontend app that can call your API over HTTPS in production.
- Environment variables available on the server.
1
Install packages
Use `jsonwebtoken` for token signing and `bcryptjs` for password verification. Keep all signing logic on the server.
Implementation snippet
npm install jsonwebtoken bcryptjs cookie-parser
npm install -D @types/jsonwebtoken @types/cookie-parserChecklist
- Do not sign JWTs in browser code.
- Use HTTPS in production before sending auth cookies.
- Store secrets in environment variables, not in source files.
2
Create token secrets
Create separate secrets for access and refresh tokens. Separate secrets let you rotate refresh tokens without invalidating every access token strategy.
Implementation snippet
JWT_ACCESS_SECRET=replace-with-a-long-random-string
JWT_REFRESH_SECRET=replace-with-another-long-random-string
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7dWarning: Use strong random values. A weak JWT secret lets attackers forge valid sessions.
3
Create token helpers
Keep signing and verification in one module so expiry, payload shape, and algorithms stay consistent.
Implementation snippet
import jwt from "jsonwebtoken";
const accessSecret = process.env.JWT_ACCESS_SECRET!;
const refreshSecret = process.env.JWT_REFRESH_SECRET!;
export function signAccessToken(user: { id: string; role: string }) {
return jwt.sign({ sub: user.id, role: user.role }, accessSecret, {
expiresIn: process.env.JWT_ACCESS_EXPIRES_IN || "15m",
});
}
export function signRefreshToken(user: { id: string }) {
return jwt.sign({ sub: user.id, type: "refresh" }, refreshSecret, {
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
});
}
export function verifyAccessToken(token: string) {
return jwt.verify(token, accessSecret) as { sub: string; role: string };
}
export function verifyRefreshToken(token: string) {
return jwt.verify(token, refreshSecret) as { sub: string; type: string };
}4
Build the login endpoint
- Read email and password from the request body.
- Find the user by email and return the same generic error for missing user or bad password.
- Compare the submitted password with the stored hash using bcrypt.
- Create an access token and refresh token after verification.
- Send the refresh token as an httpOnly cookie and the access token in the JSON response.
Implementation snippet
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = await users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ message: "Invalid email or password" });
}
const accessToken = signAccessToken({ id: user.id, role: user.role });
const refreshToken = signRefreshToken({ id: user.id });
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/auth/refresh",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken, user: { id: user.id, email: user.email, role: user.role } });
});5
Protect API routes
- Read the Authorization header.
- Require the Bearer token format.
- Verify the access token signature and expiry.
- Attach the user ID and role to the request object.
- Return 401 for missing or expired tokens before reaching the route handler.
Implementation snippet
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return res.status(401).json({ message: "Authentication required" });
try {
const payload = verifyAccessToken(token);
req.user = { id: payload.sub, role: payload.role };
next();
} catch {
res.status(401).json({ message: "Session expired" });
}
}
app.get("/api/me", requireAuth, async (req, res) => {
const user = await users.findById(req.user.id);
res.json({ user });
});6
Refresh expired access tokens
- Create a `/auth/refresh` route that reads the refresh token cookie.
- Verify the refresh token with the refresh secret.
- Optionally check a refresh-token version or session row in the database.
- Issue a new short-lived access token.
- Return 401 and clear the cookie if the refresh token is invalid.
Implementation snippet
app.post("/auth/refresh", async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ message: "Missing refresh token" });
try {
const payload = verifyRefreshToken(token);
if (payload.type !== "refresh") throw new Error("Invalid token type");
const user = await users.findById(payload.sub);
if (!user) throw new Error("User not found");
res.json({ accessToken: signAccessToken({ id: user.id, role: user.role }) });
} catch {
res.clearCookie("refreshToken", { path: "/auth/refresh" });
res.status(401).json({ message: "Invalid refresh token" });
}
});7
Use tokens on the frontend
- Store the access token in memory or a short-lived state store.
- Attach `Authorization: Bearer <token>` to protected API requests.
- When an API request returns 401, call `/auth/refresh` once and retry.
- If refresh fails, clear auth state and send the user to login.
Warning: Avoid storing long-lived JWTs in localStorage. If JavaScript can read the token, an XSS issue can steal it.
8
Verify the implementation
Checklist
- Login returns an access token and sets an httpOnly refresh cookie.
- Protected routes reject missing, malformed, and expired access tokens.
- Refresh returns a new access token while the refresh cookie is valid.
- Logout clears the refresh cookie and client auth state.
- Changing the JWT secret invalidates old tokens as expected.