Skip to content
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-parser
Checklist
  • 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=7d
Warning: 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

  1. Read email and password from the request body.
  2. Find the user by email and return the same generic error for missing user or bad password.
  3. Compare the submitted password with the stored hash using bcrypt.
  4. Create an access token and refresh token after verification.
  5. 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

  1. Read the Authorization header.
  2. Require the Bearer token format.
  3. Verify the access token signature and expiry.
  4. Attach the user ID and role to the request object.
  5. 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

  1. Create a `/auth/refresh` route that reads the refresh token cookie.
  2. Verify the refresh token with the refresh secret.
  3. Optionally check a refresh-token version or session row in the database.
  4. Issue a new short-lived access token.
  5. 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

  1. Store the access token in memory or a short-lived state store.
  2. Attach `Authorization: Bearer <token>` to protected API requests.
  3. When an API request returns 401, call `/auth/refresh` once and retry.
  4. 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.

Need implementation help?

Want this built correctly in your codebase?

Send us your stack, repo context, and the feature you need. We will help you implement it cleanly and hand over the working code.

Free scoping callFixed timelineFull source ownership
Get implementation help