The complete flow: password hashing, JWTs, refresh tokens, OAuth 2.0, and the hardening details most tutorials skip.
Every Node.js authentication tutorial covers the happy path: hash the password with bcrypt, sign a JWT, verify it in middleware, done. Then you ship it, and within a month something hurts. A token in localStorage gets stolen by an XSS payload. A refresh token that never expires becomes a permanent master key. A password reset endpoint gets hammered until someone finds a user. The happy path is real, but it is a fraction of the discipline.
This guide is the full implementation I ship for client backends, from password hashing to OAuth 2.0 to cookie hardening. It is deliberately production-shaped: short-lived access tokens, rotating refresh tokens, httpOnly cookies, CSRF protection, and rate limiting on every auth route. Follow it end to end and you get authentication that survives contact with the internet.
The architecture in one diagram
┌─────────────┐ credentials ┌──────────────────┐
│ Client │ ───────────────▶ │ POST /auth/login │
└─────────────┘ └────────┬─────────┘
▲ │ verify bcrypt hash
│ Set-Cookie (httpOnly, ▼
│ Secure, SameSite) accessToken (15m)
│ + refreshToken (7d) refreshToken (7d, hashed, DB)
│ │
│ Authorization: Bearer ◀───────┘
└──── every /api route ── requireAuth middleware
Two token types, two lifetimes, two storage locations. That separation is the entire security model.
Step 1: Set up the project and dependencies
npm init -y
npm install express jsonwebtoken bcrypt cookie-parser passport \
passport-google-oauth20 dotenv
-
express— the web framework. -
jsonwebtoken— signing and verifying JWTs. -
bcrypt— password hashing. -
cookie-parser— reading cookies (for the httpOnly refresh token). -
passport+passport-google-oauth20— OAuth 2.0 with Google. -
dotenv— loading secrets from.env(never committed).
Step 2: Hash passwords with bcrypt
Never store a plaintext password, never store a hash you invented, and never use a fast hash like MD5 or SHA-256. bcrypt is deliberately slow — that slowness is the point. Use a cost factor of 12:
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(plain) {
return bcrypt.hash(plain, SALT_ROUNDS);
}
export async function verifyPassword(plain, hash) {
return bcrypt.compare(plain, hash);
}
Why 12? Cost factor 10 takes about 80 ms on modern hardware; 12 takes around 300 ms. The difference is invisible to a legit user logging in once and enormous to an attacker running a dictionary attack — every guess costs them a third of a second. Raise it to 13–14 for high-security systems. Lower it only if profiling proves your login route is a bottleneck, which is rare.
Store the hash in your users table alongside the email and a created_at. That is the entire user record you need for password auth:
await db.users.insert({
email,
password_hash: await hashPassword(password),
});
Step 3: The login route and token issuance
On login, verify the password, then issue two tokens with different jobs:
import jwt from 'jsonwebtoken';
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
export function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, roles: user.roles },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: ACCESS_TTL },
);
}
app.post('/auth/login', rateLimit({ windowMs: 15 * 60 * 1000, max: 10 }), async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(password, user.password_hash))) {
return res.status(401).json({ error: 'invalid credentials' });
}
const accessToken = signAccessToken(user);
const refreshToken = crypto.randomUUID(); // opaque, not a JWT
const refreshHash = await hashPassword(refreshToken); // hashed at rest
await db.refreshTokens.insert({
userId: user.id,
tokenHash: refreshHash,
expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000),
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true, Secure: true, SameSite: 'strict',
path: '/auth/refresh', maxAge: 7 * 24 * 3600 * 1000,
});
res.json({ accessToken });
});
Read that carefully, because three decisions in there are what separate this from a tutorial:
- The access token is a JWT, short-lived (15 minutes). A stolen access token is useless in a quarter of an hour.
- The refresh token is a random opaque string, hashed in the database. This is the detail almost everyone gets wrong. If your refresh token is itself a JWT stored in the DB, a database leak hands the attacker valid credentials. A hashed opaque token makes a leak useless — and you can rotate it on every use.
- The refresh token lives in an httpOnly cookie scoped to the refresh path, not in localStorage. JavaScript cannot read it, which kills the XSS exfiltration vector that plagues localStorage-based auth.
Step 3.5: Registration with email verification
Registration follows the same discipline with two extra requirements: a stricter rate limit, because it is the account-creation spam surface, and email verification, because an unverified email is how accounts get squatted.
app.post('/auth/register', rateLimit({ windowMs: 60 * 60 * 1000, max: 5 }), async (req, res) => {
const { email, password } = req.body;
if (await db.users.findByEmail(email)) {
return res.status(409).json({ error: 'email already registered' });
}
const user = await db.users.insert({
email,
password_hash: await hashPassword(password),
verified: false,
});
const token = jwt.sign(
{ sub: user.id, purpose: 'email_verify' },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '24h' },
);
await mailer.send({
to: email,
subject: 'Verify your email',
text: `Verify your account: ${process.env.APP_URL}/verify?token=${token}`,
});
res.status(201).json({ message: 'check your email to verify' });
});
Do not auto-login on registration until the email is verified, and do not sign access tokens for unverified accounts. The purpose: 'email_verify' claim keeps this token distinct from access tokens — verify it against that claim, never reuse it as a login. This adds a small table lookup to your login route (if (!user.verified) return 403), and it is worth every line.
Step 4: The refresh route with rotation
Refresh tokens should be single-use. Every refresh issues a new refresh token and invalidates the old one, so a stolen token is only usable once — and if it is reused after that, you know it was compromised.
app.post('/auth/refresh', async (req, res) => {
const token = req.cookies.refresh_token;
if (!token) return res.status(401).json({ error: 'missing refresh token' });
const stored = await db.refreshTokens.findByHash(await hashPassword(token));
if (!stored || stored.expiresAt < new Date()) {
return res.status(401).json({ error: 'invalid refresh token' });
}
if (stored.revokedAt) {
await db.refreshTokens.revokeAllForUser(stored.userId); // replay = compromise
return res.status(401).json({ error: 'session revoked' });
}
const user = await db.users.findById(stored.userId);
const newRefresh = crypto.randomUUID();
await db.refreshTokens.revoke(stored.id);
await db.refreshTokens.insert({
userId: user.id,
tokenHash: await hashPassword(newRefresh),
expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000),
});
res.cookie('refresh_token', newRefresh, refreshCookieOptions());
res.json({ accessToken: signAccessToken(user) });
});
The replay-detection line — revoking the whole session when a revoked token is presented — is how you turn a stolen refresh token into a tripwire instead of a backdoor.
Step 5: The auth middleware
Protect every /api route with a middleware that verifies the access token:
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 = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
next();
} catch {
return res.status(401).json({ error: 'invalid or expired token' });
}
}
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.users.findById(req.user.sub);
res.json(user);
});
The critical discipline: every handler reads identity from req.user.sub, never from the request body. The token is the only source of truth, which means a user can never claim to be someone else by editing a request.
Step 6: OAuth 2.0 with Google
OAuth removes the password problem entirely for users who sign in with Google. The server-side flow is: redirect the user to Google → Google redirects back with an authorization code → your server exchanges the code for a token and the user's profile → you create or fetch the user → you issue your own tokens, exactly like login.
Configure the strategy with Passport:
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback',
},
async (accessToken, refreshToken, profile, done) => {
let user = await db.users.findByGoogleId(profile.id);
if (!user) {
user = await db.users.insert({
email: profile.emails[0].value,
google_id: profile.id,
display_name: profile.displayName,
});
}
return done(null, user);
},
));
The two routes:
app.get('/auth/google',
passport.authenticate('google', { scope: ['email', 'profile'] }));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
const accessToken = signAccessToken(req.user);
const refreshToken = crypto.randomUUID();
// store hashed refresh token, set cookie, redirect to the app with a session
res.redirect('/dashboard');
});
Two security notes on OAuth. First, state — Passport generates and validates it for you; never disable it, because a missing state check lets an attacker mount a login CSRF. Second, in production the callback URL must be HTTPS and match the one registered in the Google console exactly — a mismatch is the classic "works locally, 400s in production" bug.
Step 7: Harden the whole thing
Authentication is only as strong as its edges. Five things to do before you deploy:
- CSRF protection. Since the refresh token rides in a cookie, a cross-site request could trigger it. Protect the refresh route with a SameSite cookie (strict or lax) plus a CSRF token or a custom header on the refresh request. SameSite alone is not enough if you have any cross-site consumers.
-
Rate limiting on auth routes. Login, register, refresh, and password reset all need limits — they are the brute-force surface.
express-rate-limitwith 10–15 requests per 15 minutes per IP on login is a sane starting point. - Lockout and monitoring. Add an exponential backoff after repeated failed logins per account, and log every auth event. When a user's account is being hammered, you want to know before they do.
-
Secret hygiene.
JWT_ACCESS_SECRETand the Google client secret live in.env, gitignored, rotated on a schedule. A leaked secret is a full account-takeover vector. -
HTTPS only. In production,
Secure: trueon the cookie means the refresh token never crosses plain HTTP. Use a proxy (Nginx/Traefik) to terminate TLS and trust it explicitly in Express.
Logout and revocation
Logout must kill the session server-side, not just clear a cookie client-side — otherwise a stolen refresh token survives the user "logging out":
app.post('/auth/logout', requireAuth, async (req, res) => {
await db.refreshTokens.revokeAllForUser(req.user.sub);
res.clearCookie('refresh_token', refreshCookieOptions());
res.json({ ok: true });
});
Revoke the whole token family for the user, not just the presented one — if one refresh token is in the wild, its siblings probably are too. Apply the same revokeAllForUser on password change and on security-triggered events. Revocation is why you keep a token database at all: JWTs cannot be revoked until they expire, but the refresh family behind them can be killed in one query.
The pitfalls (in the order they hurt)
- JWTs in localStorage. Accessible to any script on your origin. XSS → account takeover. Put the refresh token in an httpOnly cookie and keep the access token in memory.
- Long-lived access tokens. A 30-day JWT is a permanent key if leaked. Fifteen minutes is the sweet spot; the refresh flow makes it painless.
- Refresh tokens stored as raw JWTs. If your DB leaks, so do the credentials. Hash them; an opaque random string hashed with bcrypt is useless to an attacker.
- Missing refresh-token rotation. A non-rotating refresh token is valid until it expires — weeks of exposure. Rotate on every use and revoke the family on replay.
- No rate limiting on auth routes. Your login endpoint is your front door; leave it unlocked and it will be picked. Limit it.
-
Verifying identity from the body. If any route trusts a client-supplied
userId, any authenticated user can read or write anyone's data. Identity comes only from the verified token. - Reusing one secret for everything. Separate secrets for access and refresh signing at minimum. If one leaks, the other still protects the system.
- Bcrypt cost too low. Cost 10 is the bare minimum; 12 is the default I ship. The extra 200 ms per login is the cheapest security you will ever buy.
The full checklist
Before your auth system touches real users:
- [ ] Passwords hashed with bcrypt, cost factor ≥ 12
- [ ] Access token: JWT, 15-minute lifetime,
sub= user id - [ ] Refresh token: opaque, hashed at rest, rotated on every use
- [ ] Refresh token in an httpOnly + Secure + SameSite cookie
- [ ] Replay of a revoked refresh token revokes the whole session
- [ ] Registration requires email verification before issuing access tokens
- [ ] Logout revokes the entire token family, not just the presented token
- [ ]
/apiroutes protected byrequireAuth, identity from token only - [ ] OAuth 2.0 with state validation and HTTPS callback
- [ ] Rate limiting on login, register, refresh, and reset routes
- [ ] Secrets in
.env, gitignored, separate per purpose - [ ] CSRF protection on cookie-based routes
- [ ] Auth events logged; failed-login backoff in place
What this gets you
The reason all these pieces matter is that authentication fails one way in tutorials and a hundred ways in production. The short access token limits the blast radius of a leak. The rotating, hashed refresh token makes the database useless to an attacker and turns theft into a visible tripwire. The httpOnly cookie removes the XSS vector. The rate limits slow down the brute-force machine. Each piece is small; together they are the difference between a demo and a system you can sleep next to.
Build it once, protect every route through the middleware, and the auth layer stops being the thing you worry about. Then you can get back to building the actual product.
*Gulshan Yad
Top comments (0)