This guide walks you from an empty scaffold to a fully working cookie based auth service. It's the guide to successfully complete the exercise here -> https://github.com/WebDev-WBSCodingSchool/ts-auth-server-starter
It includes:
- password hashing with bcrypt
- JWT access tokens (short lived)
- opaque refresh tokens with rotation and TTL in Mongo
- secure httpOnly cookies for both tokens
- routes: register, login, me, refresh, logout
- validation with Zod
- global error handling
- 404 handling
- environment variable validation
When you are done, your src folder should look like this:
src
├─ app.ts
├─ db.ts
├─ config
│ └─ index.ts
├─ controllers
│ ├─ auth.controller.ts
│ └─ index.ts
├─ middlewares
│ ├─ errorHandler.ts
│ ├─ notFoundHandler.ts
│ ├─ validateBodyZod.ts
│ └─ index.ts
├─ models
│ ├─ RefreshToken.ts
│ ├─ User.ts
│ └─ index.ts
├─ routes
│ ├─ auth.route.ts
│ └─ index.ts
├─ schemas
│ ├─ auth.schemas.ts
│ └─ index.ts
└─ utils
├─ jwt.ts
├─ tokens.ts
└─ index.tsNode does not read .env files automatically. Install dotenv and its types:
npm install dotenv
npm install -D @types/dotenvIn the project root (same level as package.json), create a file called .env.development.local with the following values:
MONGO_URI=mongodb+srv://YOUR_USER:YOUR_PASS@cluster0.xxxxxx.mongodb.net
DB_NAME=your-db-name-here
SALT_ROUNDS=13
ACCESS_JWT_SECRET=8c6e3b8f0a86b1cc5d0f1f4aadc7a3f5e7b9a2c48d93f57e0c4d7a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2
REFRESH_TOKEN_TTL=2592000
CLIENT_BASE_URL=http://localhost:5173
PORT=8080What these mean:
- MONGO_URI is your cluster connection string
- DB_NAME is the database name inside the cluster
- SALT_ROUNDS controls bcrypt cost factor
- ACCESS_JWT_SECRET is used to sign JWT access tokens (must be at least 64 chars)
- REFRESH_TOKEN_TTL is how long refresh tokens stay valid in seconds, example above is 30 days
- CLIENT_BASE_URL is your frontend origin so that CORS can allow cookies
Your config file uses Zod to validate env vars early and kill the process if something is missing or malformed.
// src/config/index.ts
import { z } from 'zod/v4';
const envSchema = z.object({
MONGO_URI: z.url({ protocol: /mongodb/ }),
DB_NAME: z.string(),
REFRESH_TOKEN_TTL: z.coerce.number().default(30 * 24 * 60 * 60),
SALT_ROUNDS: z.coerce.number().default(13),
ACCESS_JWT_SECRET: z
.string({
error: 'ACCESS_JWT_SECRET is required and must be at least 64 characters long'
})
.min(64),
CLIENT_BASE_URL: z.url().default('http://localhost:5173')
});
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
console.error('❌ Invalid environment variables:', z.prettifyError(parsedEnv.error));
process.exit(1);
}
export const {
ACCESS_JWT_SECRET,
DB_NAME,
CLIENT_BASE_URL,
MONGO_URI,
REFRESH_TOKEN_TTL,
SALT_ROUNDS
} = parsedEnv.data;Update src/app.ts so that dotenv loads before anything that uses process.env.
// src/app.ts
import 'dotenv/config'; // load .env.* first
import '#db';
import cors from 'cors';
import express from 'express';
import cookieParser from 'cookie-parser';
import { authRouter } from '#routes';
import { errorHandler, notFoundHandler } from '#middlewares';
import { CLIENT_BASE_URL } from '#config';
const app = express();
const port = process.env.PORT || '3000';
app.use(
cors({
origin: CLIENT_BASE_URL,
credentials: true,
exposedHeaders: ['WWW-Authenticate']
})
);
app.use(express.json(), cookieParser());
app.use('/auth', authRouter);
app.use('*', notFoundHandler);
app.use(errorHandler);
app.listen(port, () => {
console.log(`Auth Server listening on port ${port}`);
});We connect to MongoDB once on startup. The import of #db in app.ts will trigger this file.
// src/db.ts
import mongoose from 'mongoose';
import { MONGO_URI, DB_NAME } from '#config';
try {
const client = await mongoose.connect(MONGO_URI, { dbName: DB_NAME });
console.log('✅ Connected to MongoDB: ' + client.connection.name);
} catch (err) {
console.error('❌ MongoDB connection error:', err);
process.exit(1);
}We use Zod to validate incoming request bodies for register and login. This prevents garbage input from ever reaching the controller logic.
// src/schemas/auth.schemas.ts
import { z } from 'zod/v4';
const emailError = 'Please provide a valid email address.';
const emailSchema = z.string({ error: emailError }).trim().email({ error: emailError });
const basePasswordSchema = z
.string({ error: 'Password must be a string' })
.min(12, { error: 'Password must be at least 12 characters.' })
.max(512, { error: 'The length of this Password is excessive.' });
export const registerSchema = z
.object(
{
email: emailSchema,
password: basePasswordSchema
.regex(/[a-z]/, { error: 'Password must include at least one lowercase letter.' })
.regex(/[A-Z]/, { error: 'Password must include at least one uppercase letter.' })
.regex(/[0-9]/, { error: 'Password must include at least one number.' })
.regex(/[^a-zA-Z0-9]/, {
error: 'Password must include at least one special character'
}),
confirmPassword: z.string(),
firstName: z.string().min(1).max(50).optional(),
lastName: z.string().min(1).max(50).optional()
},
{ error: 'Please provide a valid email and a secure password.' }
)
.strict()
.refine(data => data.password === data.confirmPassword, { error: 'Passwords do not match' });
export const loginSchema = z.object({
email: emailSchema,
password: basePasswordSchema
});Create an index re export file:
// src/schemas/index.ts
export { loginSchema, registerSchema } from './auth.schemas';We need two collections: User and RefreshToken.
Requirements:
- firstName, lastName, email, password, roles
- roles is an array of strings with default ['user']
- email must be unique
- we never store confirmPassword
- password is stored as a bcrypt hash, never plaintext
// src/models/User.ts
import { Schema, model } from 'mongoose';
const userSchema = new Schema(
{
firstName: {
type: String,
trim: true,
maxLength: 50
},
lastName: {
type: String,
trim: true,
maxLength: 50
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true
},
password: {
type: String,
required: true
},
roles: {
type: [String],
default: ['user']
}
},
{
timestamps: { createdAt: true, updatedAt: false },
versionKey: false,
toJSON: {
transform(_doc, ret: any) {
delete ret.password;
return ret;
}
}
}
);
const User = model('User', userSchema);
export default User;Requirements:
- token field is an opaque random string
- we associate the token with a userId
- we add expireAt with a TTL
- MongoDB will auto delete expired tokens
// src/models/RefreshToken.ts
import { Schema, model, Types } from 'mongoose';
import { REFRESH_TOKEN_TTL } from '#config';
const refreshTokenSchema = new Schema(
{
token: {
type: String,
required: true,
unique: true
},
userId: {
type: Types.ObjectId,
ref: 'User',
required: true
},
// MongoDB will automatically delete a document after `expires` seconds past this Date
expireAt: {
type: Date,
default: () => new Date(Date.now() + REFRESH_TOKEN_TTL * 1000),
expires: REFRESH_TOKEN_TTL
}
},
{
timestamps: { createdAt: true, updatedAt: false },
versionKey: false
}
);
const RefreshToken = model('RefreshToken', refreshTokenSchema);
export default RefreshToken;We want to be able to import from #models.
// src/models/index.ts
export { default as User } from './User.ts';
export { default as RefreshToken } from './RefreshToken.ts';We need helpers to create JWT access tokens and to generate random refresh token strings.
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
import { ACCESS_JWT_SECRET } from '#config';
const ACCESS_TOKEN_EXPIRES_IN = '15m';
export type AccessTokenPayload = {
userId: string;
roles: string[];
};
export function createAccessToken(payload: AccessTokenPayload): string {
return jwt.sign(payload, ACCESS_JWT_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRES_IN
});
}
export function verifyAccessToken(token: string): AccessTokenPayload {
return jwt.verify(token, ACCESS_JWT_SECRET) as AccessTokenPayload;
}Refresh tokens in this design are opaque random strings. They are not JWTs.
// src/utils/tokens.ts
import { randomUUID } from 'node:crypto';
export function generateRefreshTokenString(): string {
return randomUUID();
}// src/utils/index.ts
export * from './jwt.ts';
export * from './tokens.ts';We need:
- validateBodyZod to check req.body using Zod schemas
- notFoundHandler for 404
- errorHandler to centralize error responses
// src/middlewares/validateBodyZod.ts
import type { RequestHandler } from 'express';
import type { ZodTypeAny } from 'zod';
import { z } from 'zod/v4';
export const validateBodyZod =
<T extends ZodTypeAny>(schema: T): RequestHandler =>
(req, _res, next) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
const err = new Error(z.prettifyError(parsed.error));
(err as any).statusCode = 400;
return next(err);
}
req.body = parsed.data as any;
next();
};// src/middlewares/notFoundHandler.ts
import type { RequestHandler } from 'express';
export const notFoundHandler: RequestHandler = (_req, res) => {
res.status(404).json({ error: 'Route not found' });
};// src/middlewares/errorHandler.ts
import type { ErrorRequestHandler } from 'express';
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
const status = (err as any).statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({ error: message });
};// src/middlewares/index.ts
export { notFoundHandler } from './notFoundHandler.ts';
export { errorHandler } from './errorHandler.ts';
export { validateBodyZod } from './validateBodyZod.ts';Controllers implement all logic for register, login, refresh, logout, and me.
Important concepts:
- Passwords are always hashed with bcrypt before storing
- Access token is a JWT that lasts 15 minutes
- Refresh token is a random string stored in Mongo and also in a cookie
- Refresh endpoint deletes the old refresh token and replaces it (token rotation)
- me endpoint returns user info if access token is still valid
- If the access token is expired, me responds 401 and sets header WWW-Authenticate: token_expired so the frontend knows to call refresh
Final version of src/controllers/auth.controller.ts:
// src/controllers/auth.controller.ts
import type { RequestHandler } from 'express';
import bcrypt from 'bcrypt';
import { z } from 'zod/v4';
import { User, RefreshToken } from '#models';
import {
createAccessToken,
verifyAccessToken,
generateRefreshTokenString
} from '#utils';
import { REFRESH_TOKEN_TTL, SALT_ROUNDS } from '#config';
import { loginSchema, registerSchema } from '#schemas';
type RegisterBody = z.infer<typeof registerSchema>;
type LoginBody = z.infer<typeof loginSchema>;
const ACCESS_COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'strict' as const,
secure: false,
maxAge: 15 * 60 * 1000
};
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'strict' as const,
secure: false,
maxAge: REFRESH_TOKEN_TTL * 1000
};
export const register: RequestHandler = async (req, res, next) => {
try {
const { email, password, firstName, lastName } = req.body as Omit<
RegisterBody,
'confirmPassword'
>;
const existing = await User.findOne({ email }).lean();
if (existing) {
const err = new Error('Email already registered');
(err as any).statusCode = 409;
throw err;
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const newUser = await User.create({
email,
firstName,
lastName,
password: passwordHash,
roles: ['user']
});
const accessToken = createAccessToken({
userId: newUser._id.toString(),
roles: newUser.roles
});
const refreshTokenString = generateRefreshTokenString();
await RefreshToken.create({
token: refreshTokenString,
userId: newUser._id
});
res.cookie('accessToken', accessToken, ACCESS_COOKIE_OPTIONS);
res.cookie('refreshToken', refreshTokenString, REFRESH_COOKIE_OPTIONS);
res.status(201).json({
message: 'User registered',
user: {
id: newUser._id,
email: newUser.email,
firstName: newUser.firstName,
lastName: newUser.lastName,
roles: newUser.roles
}
});
} catch (err) {
next(err);
}
};
export const login: RequestHandler = async (req, res, next) => {
try {
const { email, password } = req.body as LoginBody;
const user = await User.findOne({ email });
if (!user) {
const err = new Error('Incorrect credentials');
(err as any).statusCode = 401;
throw err;
}
const ok = await bcrypt.compare(password, user.password);
if (!ok) {
const err = new Error('Incorrect credentials');
(err as any).statusCode = 401;
throw err;
}
const accessToken = createAccessToken({
userId: user._id.toString(),
roles: user.roles
});
const refreshTokenString = generateRefreshTokenString();
await RefreshToken.create({
token: refreshTokenString,
userId: user._id
});
res.cookie('accessToken', accessToken, ACCESS_COOKIE_OPTIONS);
res.cookie('refreshToken', refreshTokenString, REFRESH_COOKIE_OPTIONS);
res.status(200).json({
message: 'Logged in',
user: {
id: user._id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles: user.roles
}
});
} catch (err) {
next(err);
}
};
export const refresh: RequestHandler = async (req, res, next) => {
try {
const oldRefreshToken = req.cookies?.refreshToken;
if (!oldRefreshToken) {
const err = new Error('No refresh token provided');
(err as any).statusCode = 401;
throw err;
}
const existing = await RefreshToken.findOne({ token: oldRefreshToken });
if (!existing) {
const err = new Error('Invalid refresh token');
(err as any).statusCode = 401;
throw err;
}
const user = await User.findById(existing.userId);
if (!user) {
const err = new Error('Invalid session');
(err as any).statusCode = 401;
throw err;
}
await RefreshToken.deleteOne({ _id: existing._id });
const newAccessToken = createAccessToken({
userId: user._id.toString(),
roles: user.roles
});
const newRefreshTokenString = generateRefreshTokenString();
await RefreshToken.create({
token: newRefreshTokenString,
userId: user._id
});
res.cookie('accessToken', newAccessToken, ACCESS_COOKIE_OPTIONS);
res.cookie('refreshToken', newRefreshTokenString, REFRESH_COOKIE_OPTIONS);
res.status(200).json({
message: 'Tokens refreshed'
});
} catch (err) {
next(err);
}
};
export const logout: RequestHandler = async (req, res, next) => {
try {
const refreshTokenCookie = req.cookies?.refreshToken;
if (refreshTokenCookie) {
await RefreshToken.deleteOne({ token: refreshTokenCookie });
}
res.clearCookie('accessToken', ACCESS_COOKIE_OPTIONS);
res.clearCookie('refreshToken', REFRESH_COOKIE_OPTIONS);
res.status(204).send();
} catch (err) {
next(err);
}
};
export const me: RequestHandler = async (req, res, next) => {
try {
// 1. Get access token from cookies
const accessToken = req.cookies?.accessToken;
if (!accessToken) {
const err = new Error('Not authenticated');
(err as any).statusCode = 401;
throw err;
}
// 2. Verify it
let decoded: { userId: string; roles: string[] };
try {
decoded = verifyAccessToken(accessToken);
} catch (tokenErr: any) {
if (tokenErr && tokenErr.name === 'TokenExpiredError') {
// Tell frontend: access token is expired, go hit /auth/refresh
res.setHeader('WWW-Authenticate', 'token_expired');
res.status(401).json({ error: 'Access token expired' });
return; // <- return with no value, so TS is happy
}
const err = new Error('Invalid token');
(err as any).statusCode = 401;
throw err;
}
// 3. Look up the user from the decoded token
const user = await User.findById(decoded.userId).lean();
if (!user) {
const err = new Error('User not found');
(err as any).statusCode = 404;
throw err;
}
// 4. Send profile
res.status(200).json({
id: user._id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles: user.roles
});
} catch (err) {
next(err);
}
};Also double check src/controllers/index.ts:
// src/controllers/index.ts
export * from './auth.controller.ts';Wire up the endpoints and attach validation for the ones that take a body.
// src/routes/auth.route.ts
import { Router } from 'express';
import { login, logout, me, refresh, register } from '#controllers';
import { validateBodyZod } from '#middlewares';
import { loginSchema, registerSchema } from '#schemas';
const authRouter = Router();
authRouter.post('/register', validateBodyZod(registerSchema), register);
authRouter.post('/login', validateBodyZod(loginSchema), login);
authRouter.post('/refresh', refresh);
authRouter.delete('/logout', logout);
authRouter.get('/me', me);
export default authRouter;Double check also routes index:
// src/routes/index.ts
export { default as authRouter } from './auth.route.ts';- validateBodyZod(registerSchema) runs
- controller checks if the email already exists
- password is hashed using bcrypt and SALT_ROUNDS
- user is created in DB with roles ['user']
- access token and refresh token are created
- refresh token is stored in Mongo with TTL
- both tokens are sent to the browser as httpOnly cookies
- 201 response with safe user info (no password)
- validateBodyZod(loginSchema) runs
- controller finds the user by email
- bcrypt.compare checks password
- if ok, new access token and refresh token are created
- refresh token is saved in DB
- both cookies are set
- 200 response with basic user info
- reads accessToken cookie
- verifies it
- if expired, responds 401 and sets WWW-Authenticate: token_expired header so the frontend knows to call refresh
- if valid, loads the user and returns profile info
- reads refreshToken cookie
- finds it in Mongo
- deletes that old refresh token (rotation)
- creates new access token and new refresh token
- stores the new refresh token in Mongo
- sends new cookies
- looks at refreshToken cookie
- removes that token from Mongo so it can never refresh again
- clears both cookies on the client
- returns 204
This backend already works in Postman. To make it work from a browser frontend (for example Vite dev server at http://localhost:5173 talking to API at http://localhost:3000), you must handle CORS and credentials.
In app.ts:
app.use(
cors({
origin: CLIENT_BASE_URL, // for example http://localhost:5173
credentials: true, // allow cookies
exposedHeaders: ['WWW-Authenticate'] // so frontend can read token_expired
})
);Also make sure CLIENT_BASE_URL in your env file matches the real frontend origin exactly, including port.
When the browser calls the API it has to explicitly say include cookies.
Example with fetch:
const res = await fetch('http://localhost:3000/auth/me', {
method: 'GET',
credentials: 'include'
});Example with axios:
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000',
withCredentials: true
});
const res = await api.get('/auth/me');If you forget credentials: 'include' or withCredentials: true, the browser will not send the cookies and it will look like you are not logged in.
In the controller we set cookie options like this for dev:
const ACCESS_COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'strict',
secure: false,
maxAge: 15 * 60 * 1000
};
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'strict',
secure: false,
maxAge: REFRESH_TOKEN_TTL * 1000
};Notes:
- httpOnly means frontend JavaScript cannot read the cookies, which is good
- secure is false here because localhost is plain http in dev
- sameSite is strict, which is fine when frontend and backend both run on localhost even if the ports are different
In production with real domains you would need:
- secure: true (you must be on https)
- sameSite: 'none' if frontend and backend are on different subdomains
Use the same host name for both frontend and backend in dev. For example:
- frontend at http://localhost:5173
- backend at http://localhost:3000
If one runs on localhost and the other on 127.0.0.1, some browsers will treat that as different sites and refuse to send cookies with sameSite strict.
Before you run npm run dev, check that you have all of these:
- src/app.ts
- src/db.ts
- src/config/index.ts
- src/controllers/auth.controller.ts
- src/controllers/index.ts
- src/middlewares/errorHandler.ts
- src/middlewares/notFoundHandler.ts
- src/middlewares/validateBodyZod.ts
- src/middlewares/index.ts
- src/models/User.ts
- src/models/RefreshToken.ts
- src/models/index.ts
- src/routes/auth.route.ts
- src/routes/index.ts
- src/schemas/auth.schemas.ts
- src/schemas/index.ts
- src/utils/jwt.ts
- src/utils/tokens.ts
- src/utils/index.ts
- .env.development.local in project root with correct values
- dotenv installed and imported at the top of app.ts
- CLIENT_BASE_URL in env matches your frontend url
- frontend calls fetch or axios with credentials included
If all of that is true, then:
- POST /auth/register works
- POST /auth/login works
- GET /auth/me works
- POST /auth/refresh works with rotation
- DELETE /auth/logout clears the session
Congrats, you have a fully working cookie based authentication service!