The jsonwebtoken package pulls roughly 34 million downloads every week, according to Snyk’s package registry data. That single number explains why JWT authentication is both the default choice for Node.js APIs and one of the most attacked surfaces on the modern web: a single misconfiguration ripples across millions of services. This tutorial walks you through building a complete, production-shaped JWT authentication system in Node.js in 10 steps, from your first signed token to refresh-token rotation, secure cookie storage, and RS256 key separation. Budget about 45 minutes to work through it end to end.

Every code block below runs on current LTS Node.js and the verified-latest jsonwebtoken 9.0.3. You will also see exactly where JWT goes wrong, because the difference between a secure token and a forged one is usually a one-line configuration mistake. By the end you will have a working Express project that logs users in, issues access and refresh tokens, verifies them, protects routes, and revokes sessions on logout.

What JWT Authentication Actually Does (and Where It Breaks)

A JSON Web Token is a signed, self-contained credential. After a user proves their identity once (with a password, a one-time code, or a social login), your server hands them a token that encodes who they are and what they can do. On every later request, the client sends that token back, and your server verifies the signature instead of hitting the database for a session lookup. That statelessness is the entire appeal: any server holding the signing key can validate the token, which makes JWT a natural fit for APIs, microservices, and horizontally scaled backends.

The trade-off is that the security of the whole scheme collapses to the signature. A JWT is not encrypted by default. Anyone who intercepts one can read its contents by base64-decoding the middle segment. What they cannot do, if you have configured things correctly, is change a single byte without invalidating the signature. The phrase “if you have configured things correctly” is doing enormous work in that sentence, and most of this tutorial exists to make sure you land on the correct side of it.

JWT authentication breaks in three classic ways. First, the server trusts the alg field inside the token and lets an attacker downgrade it to none, accepting an unsigned forgery. Second, secrets are weak, reused, or committed to a Git repository, so attackers sign their own tokens. Third, tokens live too long or are stored where cross-site scripting can steal them, turning one stolen token into a long-lived account takeover. Each step below is built to close one of those doors. If you want a refresher on how the underlying math keeps a signature tamper-evident, our explainer on digital signatures covers the same primitives JWT relies on.

Prerequisites and Versions for This JWT Tutorial

Pin your versions. JWT bugs are subtle, and “it works on my machine” usually means “my machine runs a different minor version.” Everything in this guide was written against the releases in the table below, all confirmed as the current published versions on the npm registry as of June 2026.

Tool / PackageVersionWhy it matters here
Node.js22 LTS or 24 LTSNode 20 leaves active support in April 2026; 22 and 24 are the supported LTS lines
jsonwebtoken9.0.3The core sign/verify library; 9.x removed insecure defaults present in 8.x
express5.2.1HTTP routing for the login and protected endpoints
bcrypt6.0.0Hashes the password before you ever issue a token
cookie-parser1.4.7Reads httpOnly cookies on incoming requests
jose6.2.3The standards-focused alternative shown in the advanced section
dotenvlatestKeeps your signing secret out of source code

You also need working knowledge of JavaScript promises and async/await, a terminal, and a REST client such as curl or an API tester for hitting the endpoints. Basic familiarity with how passwords should be stored helps too; if hashing is new to you, read our companion tutorial on Argon2 password hashing in Node.js before issuing tokens to real users. A token is only as trustworthy as the login that produced it.

Anatomy of a JSON Web Token

Before you generate one, understand what you are generating. A JWT is three base64url-encoded segments joined by dots: header.payload.signature. The header names the signing algorithm. The payload carries the claims, the data about the user. The signature binds the first two parts to a key so they cannot be altered.

Decode a real token and the header looks like {"alg":"HS256","typ":"JWT"} and the payload like {"sub":"1234","name":"Ada","iat":1718000000,"exp":1718000900}. The RFC 7519 specification defines a set of registered claims you should use rather than inventing your own: iss (issuer), sub (subject, usually the user ID), aud (audience), exp (expiry), iat (issued at), and nbf (not before). Sticking to these standard claims keeps your tokens interoperable with libraries and gateways that already understand them.

ClaimMeaningTutorial usage
subSubject (the user)The database user ID
iatIssued at (epoch seconds)Set automatically by the library
expExpiration timeDriven by your expiresIn option
issIssuerYour API’s identifier
audAudienceWhich service may consume the token
jtiJWT IDUnique ID used for revocation lists

The critical insight: the payload is encoded, not encrypted. Never put a password, a credit card number, or any secret in a JWT payload. Treat every claim as world-readable, because to anyone holding the token, it is. The signature protects integrity, not confidentiality. If you need confidentiality, you encrypt the transport with TLS, which our guide to HTTPS and TLS explains in plain terms, or you reach for JWE, a separate encrypted-token standard outside the scope of this tutorial.

Step 1 and 2: Project Setup and Installing jsonwebtoken

Create a project directory, initialize it, and install the dependencies. The jsonwebtoken library is maintained by Auth0 and is the most widely deployed JWT implementation in the Node.js ecosystem.

mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y

# Set the project to ES modules so import syntax works
npm pkg set type="module"

# Install the runtime dependencies, pinned to current versions
npm install [email protected] [email protected] [email protected] \
  [email protected] dotenv

# Confirm what landed
npm ls --depth=0

You should see [email protected] in the output. Version 9 matters. The 8.x line had looser defaults around algorithm handling, and the 9.0.0 release tightened several behaviors and shipped fixes for the issues tracked under CVE-2022-23529 and related advisories. Snyk reports no known direct vulnerabilities in 9.0.3. If npm ls shows an 8.x version because of a transitive dependency, run npm dedupe and pin explicitly.

Create the project skeleton next. You will end up with server.js for the Express app, auth.js for token logic, and a .env file that never enters version control.

# Project layout
touch server.js auth.js .env .gitignore

# Make sure secrets never get committed
printf "node_modules/\n.env\n*.pem\n" > .gitignore

That .gitignore is not optional housekeeping. Committed signing secrets are one of the most common ways JWT systems are compromised, because public and even private repositories get scraped continuously. Once a secret leaks, every token your service ever issued or will issue is forgeable until you rotate the key.

Step 3: Generate a Strong Signing Secret

For HS256, the symmetric algorithm you will start with, the entire security of your tokens rests on one secret. A short or guessable secret can be brute-forced offline, and tools that crack weak JWT secrets in seconds are freely available. The fix is simple: use high-entropy random bytes, not a memorable phrase. Generate at least 256 bits (32 bytes) of randomness so the secret is at least as long as the HMAC-SHA256 output it feeds.

# Generate a 64-byte (512-bit) random secret, hex-encoded
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

# Example output (yours will differ):
# 9f2c1a7b...e4d0  (128 hex characters)

Paste the result into .env. Keep a separate secret for refresh tokens so a leak of one class of token does not compromise the other.

# .env  (never commit this file)
ACCESS_TOKEN_SECRET=9f2c1a7b...e4d0
REFRESH_TOKEN_SECRET=3b8e0d44...a91c
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=7d
NODE_ENV=development

The randomness here comes from Node’s crypto.randomBytes, the same cryptographically secure generator used across the platform. If you want to understand why HMAC-SHA256 is trustworthy as the signing primitive behind HS256, our breakdown of SHA-256 and the broader piece on cryptographic hash functions explain the one-way properties that make signature forgery infeasible.

Step 4: Build the Login Route and Sign Your First Token

Now the core of the system. A login route verifies the password, then signs a token. Notice that we hash and compare with bcrypt first, and only sign a JWT after the password checks out. The JWT is the reward for a successful login, never the login itself.

// auth.js
import jwt from 'jsonwebtoken';
import 'dotenv/config';

export function signAccessToken(user) {
  return jwt.sign(
    { name: user.name, role: user.role },   // custom claims
    process.env.ACCESS_TOKEN_SECRET,
    {
      algorithm: 'HS256',
      subject: String(user.id),             // becomes the "sub" claim
      issuer: 'jwt-auth-demo',
      audience: 'jwt-auth-demo-clients',
      expiresIn: process.env.ACCESS_TOKEN_TTL, // "15m"
    }
  );
}

export function signRefreshToken(user) {
  return jwt.sign(
    { type: 'refresh' },
    process.env.REFRESH_TOKEN_SECRET,
    {
      algorithm: 'HS256',
      subject: String(user.id),
      expiresIn: process.env.REFRESH_TOKEN_TTL, // "7d"
    }
  );
}

Wire that into an Express server with a login endpoint. For the tutorial we use an in-memory user whose password is pre-hashed; in production this row comes from your database.

// server.js
import express from 'express';
import bcrypt from 'bcrypt';
import cookieParser from 'cookie-parser';
import 'dotenv/config';
import { signAccessToken, signRefreshToken } from './auth.js';

const app = express();
app.use(express.json());
app.use(cookieParser());

// Demo user. Password is "correct-horse" hashed with bcrypt.
const users = [{
  id: 1,
  name: 'Ada',
  role: 'admin',
  passwordHash: await bcrypt.hash('correct-horse', 12),
}];

app.post('/login', async (req, res) => {
  const { name, password } = req.body;
  const user = users.find(u => u.name === name);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

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

  const accessToken = signAccessToken(user);
  const refreshToken = signRefreshToken(user);
  res.json({ accessToken, refreshToken });
});

app.listen(3000, () => console.log('Auth API on http://localhost:3000'));

Start the server with node server.js and test the login.

curl -s -X POST http://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ada","password":"correct-horse"}'

# Expected output (token strings truncated):
# {"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...","refreshToken":"eyJ..."}

Copy the accessToken value into the JWT debugger at jwt.io and you will see your claims decoded: sub, iat, exp, iss, aud, plus the custom name and role. That readability is exactly why no secret ever belongs in there.

Step 5: Create JWT Verification Middleware

Signing is the easy half. Verification is where security is won or lost. The single most important rule of JWT verification: always pass an explicit algorithms allowlist. If you omit it, an attacker can change the token’s alg header and try to trick your server into accepting a different algorithm than you intended. Pinning the algorithm closes the most famous JWT attack class.

// auth.js (add this)
export function verifyAccessToken(token) {
  return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
    algorithms: ['HS256'],                 // explicit allowlist, critical
    issuer: 'jwt-auth-demo',
    audience: 'jwt-auth-demo-clients',
  });
}

// Express middleware that guards protected routes
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({ error: 'Missing token' });

  try {
    req.user = verifyAccessToken(token); // attaches decoded claims
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token', reason: err.name });
  }
}

The jwt.verify call does four things at once: it recomputes the signature with your secret and rejects any mismatch, it confirms the algorithm is on your allowlist, it checks that exp has not passed, and it validates the issuer and audience you supplied. Any failure throws, which is why the middleware wraps it in a try/catch and returns a clean 401. The thrown error’s name tells you which check failed, which becomes invaluable during troubleshooting.

Step 6: Protect Routes and Read the Payload

With the middleware in place, protecting an endpoint is a one-liner. Add a route that only authenticated users can reach, plus an admin-only route that reads the role claim to enforce authorization.

// server.js (add these, import requireAuth from './auth.js')
import { requireAuth } from './auth.js';

app.get('/me', requireAuth, (req, res) => {
  res.json({ id: req.user.sub, name: req.user.name, role: req.user.role });
});

app.get('/admin', requireAuth, (req, res) => {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  res.json({ secret: 'Only admins see this.' });
});

Test the protected route by sending the access token in the Authorization header.

TOKEN="paste-your-access-token-here"

curl -s http://localhost:3000/me -H "Authorization: Bearer $TOKEN"
# {"id":"1","name":"Ada","role":"admin"}

# Tamper with one character of the token and it fails:
curl -s http://localhost:3000/me -H "Authorization: Bearer ${TOKEN}x"
# {"error":"Invalid token","reason":"JsonWebTokenError"}

This is the tamper-evidence of the signature in action. Authentication (who you are) lives in the requireAuth middleware; authorization (what you may do) lives in the per-route role check. Keep those two concerns separate. Conflating them, for example trusting a role claim without re-checking sensitive operations against the database, is a frequent source of privilege-escalation bugs.

Step 7: Add Refresh Tokens for Long Sessions

Short access-token lifetimes are a security feature, not an inconvenience. A 15-minute access token means a stolen token is useless after a quarter hour. But you do not want to force a password re-entry every 15 minutes, so you pair the short access token with a longer-lived refresh token whose only job is to mint new access tokens. The recommended pattern across modern auth designs is a 5 to 15 minute access token and a refresh token measured in days or weeks.

Token typeTypical lifetimeSent on every request?Stored where
Access token5 to 15 minutesYes, in Authorization headerMemory or short-lived
Refresh token7 to 30 daysNo, only to refresh endpointhttpOnly cookie
ID token (OIDC)Matches access tokenNoClient app

Add a refresh endpoint. It verifies the refresh token against its own secret, then issues a fresh access token. A production-grade implementation also rotates the refresh token on each use and stores a server-side record so a stolen refresh token can be detected and revoked.

// auth.js (add)
export function verifyRefreshToken(token) {
  return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, {
    algorithms: ['HS256'],
  });
}

// server.js (add)
import { verifyRefreshToken } from './auth.js';

app.post('/refresh', (req, res) => {
  const token = req.cookies.refreshToken || req.body.refreshToken;
  if (!token) return res.status(401).json({ error: 'No refresh token' });

  try {
    const payload = verifyRefreshToken(token);
    const user = users.find(u => String(u.id) === payload.sub);
    if (!user) return res.status(401).json({ error: 'Unknown user' });

    const accessToken = signAccessToken(user);
    res.json({ accessToken });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

The flow from the client’s perspective: log in once, receive both tokens, use the access token until it expires, then quietly call /refresh to get a new one. Only when the refresh token itself expires does the user log in again. This keeps the attack window for any single stolen access token tiny while preserving a smooth user experience.

Step 8: Store Tokens Safely (httpOnly Cookies vs localStorage)

Where you store the token in the browser decides whether a single cross-site scripting flaw becomes a full account takeover. JavaScript can read localStorage and sessionStorage. JavaScript cannot read a cookie marked httpOnly. That difference is the whole game. If an attacker injects script into your page and your token sits in localStorage, they exfiltrate it instantly. If it sits in an httpOnly cookie, the injected script cannot touch it.

// server.js: set the refresh token as a hardened cookie at login
app.post('/login', async (req, res) => {
  // ... password check as before ...
  const accessToken = signAccessToken(user);
  const refreshToken = signRefreshToken(user);

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,                              // JS cannot read it
    secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
    sameSite: 'strict',                          // blocks CSRF on the cookie
    path: '/refresh',                            // only sent to refresh route
    maxAge: 7 * 24 * 60 * 60 * 1000,             // 7 days in ms
  });

  res.json({ accessToken }); // access token returned in body, kept in memory
});

The pattern that holds up best in practice: keep the short-lived access token in JavaScript memory only (a variable, never persisted), and keep the refresh token in an httpOnly, Secure, SameSite cookie scoped to the refresh path. A page reload wipes the in-memory access token, but the cookie silently restores a session through /refresh. The SameSite=Strict attribute also blunts cross-site request forgery against the cookie itself. None of this works without HTTPS, which is why the secure flag is mandatory in production.

StorageReadable by JS / XSSCSRF exposureSurvives reloadVerdict
localStorageYes (high risk)NoYesAvoid for tokens
In-memory variableOnly via active XSSNoNoGood for access token
httpOnly cookieNoYes (mitigate w/ SameSite)YesGood for refresh token
Non-httpOnly cookieYesYesYesWorst of both

Step 9: Switch to RS256 for Distributed Verification

HS256 uses one shared secret for both signing and verifying. That is fine when a single service does both. The moment you have multiple services that need to verify tokens, sharing one secret everywhere becomes a liability: any compromised verifier can also forge tokens. RS256 solves this with asymmetric keys. A private key signs; a public key verifies. You distribute the public key freely and guard the private key in one place.

# Generate an RSA key pair. jsonwebtoken enforces a 2048-bit minimum.
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
// auth.js: RS256 variant
import fs from 'node:fs';

const privateKey = fs.readFileSync('./private.pem');
const publicKey = fs.readFileSync('./public.pem');

export function signRS256(user) {
  return jwt.sign(
    { name: user.name, role: user.role },
    privateKey,
    { algorithm: 'RS256', subject: String(user.id), expiresIn: '15m' }
  );
}

export function verifyRS256(token) {
  // Verify with the PUBLIC key, and pin the algorithm.
  return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
}

Pinning algorithms: ['RS256'] on verify is doubly important here. Without it, an attacker can take your public key, which is published by design, and craft an HS256 token using that public key as the HMAC secret. A naive verifier that accepts both families would treat the public key as a shared secret and validate the forgery. This algorithm-confusion attack is precisely why every verify call in this tutorial carries an explicit allowlist. The library enforces a 2048-bit minimum RSA modulus by default and exposes an allowInsecureKeySizes escape hatch that you should never use in production.

Step 10: Handle Logout and Token Revocation

Here is the uncomfortable truth about JWT: a signed token is valid until it expires, full stop. There is no built-in “log out” that invalidates an already-issued token, because the server holds no session state to delete. You have three practical options, and the right choice depends on how strict your revocation needs to be.

  • Short expiry plus refresh revocation. Keep access tokens so short that revocation barely matters, and revoke the refresh token server-side. Logging out deletes the refresh-token record and clears the cookie. The access token dies on its own within minutes.
  • Denylist by jti. Give each token a unique jti claim and store revoked IDs in a fast store such as Redis with a TTL equal to the token’s remaining life. The middleware checks the denylist on every request. This restores instant revocation at the cost of a lookup.
  • Token version per user. Store a tokenVersion integer on the user row, embed it in the token, and increment it to invalidate every token that user holds at once. Useful for “log out everywhere” and forced logout after a password change.
// server.js: logout clears the refresh cookie and drops the server record
const revokedRefreshTokens = new Set(); // use Redis in production

app.post('/logout', (req, res) => {
  const token = req.cookies.refreshToken;
  if (token) revokedRefreshTokens.add(token);
  res.clearCookie('refreshToken', { path: '/refresh' });
  res.json({ ok: true });
});

// In /refresh, reject revoked tokens before issuing a new access token:
// if (revokedRefreshTokens.has(token))
//   return res.status(401).json({ error: 'Token revoked' });

For most applications, option one is enough and the simplest to operate. Reach for a denylist only when you genuinely need sub-minute revocation, for example when a token can authorize money movement. The OWASP guidance on JWT stresses exactly this balance: short expirations, careful logout handling, and a deliberate revocation strategy rather than pretending tokens can be un-issued for free.

HS256 vs RS256: Which Algorithm to Choose

The algorithm choice is really a key-management choice. HS256 is symmetric and simpler. RS256 is asymmetric and scales across trust boundaries. Neither is “more secure” in a vacuum; they fail in different ways when misused.

FactorHS256 (HMAC)RS256 (RSA)
Key modelOne shared secretPrivate signs, public verifies
Best forSingle service issuing and verifyingMany services, third-party verifiers
Secret distributionEvery verifier needs the secretOnly the signer needs the private key
PerformanceFaster to sign and verifySlower, larger signatures
Main riskWeak or leaked shared secretAlgorithm confusion if alg not pinned
Min key size256-bit random secret2048-bit RSA modulus

Rule of thumb: start with HS256 for a single monolithic API where the same process signs and verifies. Move to RS256 (or its elliptic-curve cousin ES256) when a separate service, a partner, or an API gateway must verify tokens it did not issue. ES256 deserves a mention because it produces much smaller signatures than RS256 at equivalent strength, which matters when tokens travel in headers on every request. The verification rules are identical: always pin the algorithm.

6 Common JWT Pitfalls That Cause Breaches

Most JWT incidents trace back to a handful of repeated mistakes. Each one below has a one-line fix, and each fix is already baked into the code above.

  • Accepting alg: none. The original JWT sin. An attacker sets the header algorithm to none, drops the signature, and a careless verifier accepts the unsigned token. Fix: always pass an algorithms allowlist to jwt.verify, which the RFC 8725 best-practices document treats as mandatory.
  • Algorithm confusion (RS256 to HS256). Covered in Step 9. The public key gets used as an HMAC secret. Fix: pin the exact algorithm, never accept a family.
  • Weak or hardcoded secrets. A short HS256 secret is brute-forceable offline in seconds. Fix: 256-bit-plus random secrets from crypto.randomBytes, stored outside source control.
  • No expiry, or expiry too long. A token with no exp, or a multi-day access token, turns one theft into lasting access. Fix: short access tokens (5 to 15 minutes) plus refresh tokens.
  • Secrets in the payload. Putting a password, API key, or PII in the claims, forgetting the payload is merely encoded. Fix: treat the payload as public; store only identifiers and non-sensitive claims.
  • Tokens in localStorage. One XSS flaw exfiltrates every token. Fix: httpOnly cookies for refresh tokens, in-memory storage for access tokens.

If you internalize only one principle from this list, make it the first: never trust the alg field inside an attacker-supplied token. The token tells you which algorithm it wants you to use, and an attacker controls that field completely. Your server, not the token, decides which algorithms are acceptable.

Troubleshooting Common JWT Errors

When verification fails, the jsonwebtoken library throws an error whose name and message point straight at the cause. Here are the errors you will actually hit, mapped to fixes.

Error / symptomLikely causeFix
TokenExpiredErrorThe exp claim is in the pastCall /refresh for a new access token
invalid signatureWrong secret or tampered tokenConfirm verifier uses the same secret that signed it
jwt malformedToken is not three dot-separated partsCheck you stripped the Bearer prefix correctly
invalid algorithmToken alg not on your allowlistConfirm sign and verify use the same algorithm
jwt audience invalidaud mismatchMatch the audience option on sign and verify
jwt issuer invalidiss mismatchUse identical issuer on both sides
secretOrPrivateKey must have a value.env not loadedImport dotenv/config before reading the secret
NotBeforeErrornbf claim is in the futureCheck server clock skew between signer and verifier
401 on every requestMissing or wrong Authorization header formatSend Authorization: Bearer <token> exactly

Two of these deserve extra attention. secretOrPrivateKey must have a value almost always means your environment variable came back undefined because dotenv loaded after the code that reads it; put import 'dotenv/config' at the very top. And NotBeforeError plus intermittent TokenExpiredError often signal clock skew between machines. In distributed systems, allow a few seconds of leeway with the clockTolerance option rather than loosening expiry policy.

Advanced Tips: Key Rotation, jose, and Token Binding

Once the basics work, three upgrades separate a hobby project from a production system. The first is key rotation. Secrets and keys should change on a schedule, and you cannot afford to invalidate every live token the instant you rotate. The standard solution is a kid (key ID) header that names which key signed a token, plus a small set of currently valid keys your verifier can choose from.

// Sign with a key ID so verifiers know which key to use
const token = jwt.sign(payload, currentKey.secret, {
  algorithm: 'HS256',
  keyid: currentKey.id,          // sets the "kid" header
  expiresIn: '15m',
});

// Verify by selecting the key named in the (untrusted) header,
// but still pinning the algorithm you control.
function verifyWithRotation(token, keyStore) {
  const decoded = jwt.decode(token, { complete: true });
  const key = keyStore[decoded.header.kid];
  if (!key) throw new Error('Unknown key id');
  return jwt.verify(token, key.secret, { algorithms: ['HS256'] });
}

The second upgrade is considering jose, the standards-focused library that has become many teams’ default for new projects. It aligns tightly with the JOSE specifications, supports JWS, JWE, and JWK out of the box, and exposes a modern promise-based API. The same login token looks like this with jose:

// npm install [email protected]
import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET);

const jwtToken = await new SignJWT({ name: 'Ada', role: 'admin' })
  .setProtectedHeader({ alg: 'HS256' })
  .setSubject('1')
  .setIssuedAt()
  .setExpirationTime('15m')
  .sign(secret);

const { payload } = await jwtVerify(jwtToken, secret, {
  algorithms: ['HS256'],     // jose makes the allowlist explicit too
});

Choose jsonwebtoken for a simple, battle-tested sign/verify flow, and jose when you need encrypted tokens (JWE), JWKS endpoints, or strict spec compliance. The third upgrade is sender-constrained tokens, binding a token to a client’s TLS certificate or a proof-of-possession key so a stolen bearer token alone is not enough. It is more work, but for high-value APIs it closes the residual risk that any pure bearer-token scheme carries.

For broader defensive context on how token theft fits into real-world intrusions, our overview of how data breaches happen and the practical security guide show where authentication failures sit in the larger attack chain.

The Complete Working Project

Assembled, your project is three files plus an environment file. Here is the consolidated server.js so you can run the whole thing end to end. It depends on the auth.js helpers from Steps 4, 5, 7, and 9.

// server.js (complete)
import express from 'express';
import bcrypt from 'bcrypt';
import cookieParser from 'cookie-parser';
import 'dotenv/config';
import {
  signAccessToken, signRefreshToken,
  verifyRefreshToken, requireAuth,
} from './auth.js';

const app = express();
app.use(express.json());
app.use(cookieParser());

const users = [{
  id: 1, name: 'Ada', role: 'admin',
  passwordHash: await bcrypt.hash('correct-horse', 12),
}];
const revoked = new Set();

app.post('/login', async (req, res) => {
  const { name, password } = req.body;
  const user = users.find(u => u.name === name);
  if (!user || !(await bcrypt.compare(password, user.passwordHash)))
    return res.status(401).json({ error: 'Invalid credentials' });

  const refreshToken = signRefreshToken(user);
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true, secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000,
  });
  res.json({ accessToken: signAccessToken(user) });
});

app.post('/refresh', (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token || revoked.has(token))
    return res.status(401).json({ error: 'No valid refresh token' });
  try {
    const p = verifyRefreshToken(token);
    const user = users.find(u => String(u.id) === p.sub);
    res.json({ accessToken: signAccessToken(user) });
  } catch { res.status(401).json({ error: 'Invalid refresh token' }); }
});

app.post('/logout', (req, res) => {
  if (req.cookies.refreshToken) revoked.add(req.cookies.refreshToken);
  res.clearCookie('refreshToken', { path: '/' });
  res.json({ ok: true });
});

app.get('/me', requireAuth, (req, res) =>
  res.json({ id: req.user.sub, name: req.user.name, role: req.user.role }));

app.listen(3000, () => console.log('Auth API on http://localhost:3000'));

Run the full session lifecycle from the command line to confirm everything connects.

# 1. Log in, capturing the refresh cookie into a jar
curl -s -c jar.txt -X POST http://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ada","password":"correct-horse"}'
# {"accessToken":"eyJ..."}

# 2. Refresh using the cookie jar (no body needed)
curl -s -b jar.txt -X POST http://localhost:3000/refresh
# {"accessToken":"eyJ..."}

# 3. Log out, revoking the refresh token
curl -s -b jar.txt -X POST http://localhost:3000/logout
# {"ok":true}

# 4. Refresh again now fails
curl -s -b jar.txt -X POST http://localhost:3000/refresh
# {"error":"No valid refresh token"}

That sequence exercises the entire system: issue, verify, refresh, and revoke. From here, the production checklist is swapping the in-memory user for a database, moving the revocation Set to Redis, serving everything over HTTPS, and adding rate limiting to the login route so the bcrypt comparison cannot be abused for brute force.

Frequently Asked Questions About JWT Authentication

Is JWT authentication secure?

Yes, when implemented correctly. JWT itself is a well-specified standard (RFC 7519), and its signature scheme is cryptographically sound. The risk lives in implementation: accepting alg: none, failing to pin the algorithm, using weak secrets, or storing tokens in localStorage. Follow the allowlist, short-expiry, and secure-storage practices in this tutorial and JWT is as secure as session cookies, with better scaling properties.

Should I use jsonwebtoken or jose in 2026?

Use jsonwebtoken 9.0.3 for straightforward sign-and-verify flows; it is battle-tested and downloaded around 34 million times a week. Choose jose 6.2.3 when you need encrypted tokens (JWE), JWKS endpoints, or strict alignment with the JOSE specifications. Both require you to pass an explicit algorithms allowlist on verification. Neither is a security shortcut on its own.

How long should a JWT access token last?

A widely used range is 5 to 15 minutes for access tokens, paired with refresh tokens that live for days or weeks. The exact number is a policy choice driven by your threat model: shorter expiries shrink the window a stolen token is useful, at the cost of more refresh calls. For high-value operations, lean toward the short end and add server-side revocation.

Can a JWT be revoked before it expires?

Not natively, because JWTs are stateless. You add revocation yourself: keep access tokens short and revoke the refresh token server-side, maintain a denylist of jti values in Redis, or bump a per-user token version. Each approach trades some statelessness for the ability to log a user out immediately. Most apps get by with short expiries plus refresh revocation.

Where should I store a JWT in the browser?

Keep the short-lived access token in JavaScript memory and the refresh token in an httpOnly, Secure, SameSite=Strict cookie. Avoid localStorage entirely for tokens, because any cross-site scripting flaw can read it. The httpOnly flag specifically prevents injected JavaScript from reading the cookie, which is the difference between a contained XSS bug and a full account takeover.

What is the difference between JWT authentication and a session?

A traditional session stores state on the server and gives the client an opaque session ID; the server looks it up on every request. A JWT is self-contained: the server verifies a signature instead of a database lookup, which is why it scales statelessly across many servers. The trade-off is revocation, which sessions handle trivially (delete the row) and JWT requires extra machinery for.

Does HTTPS make JWT secure on its own?

HTTPS is necessary but not sufficient. TLS protects the token in transit so it cannot be sniffed on the wire, which is why the secure cookie flag is mandatory in production. But HTTPS does nothing about weak secrets, missing algorithm allowlists, or tokens stolen through XSS on the client. You need both transport security and correct token handling.

What is the alg:none attack?

It is a forgery where the attacker sets the token’s header algorithm to none and removes the signature entirely. A verifier that trusts the header and skips signature checking accepts the unsigned token as valid, letting the attacker impersonate anyone. The defense is absolute: always pass an explicit algorithms allowlist to your verify call so none is never accepted. RFC 8725 lists this as a required mitigation.

External references worth bookmarking: the JWT specification (RFC 7519), the JWT Best Current Practices (RFC 8725), the OWASP JWT cheat sheet, the jsonwebtoken source and docs, and the Node.js release schedule to track which LTS lines you should target.