Skip to content

Instantly share code, notes, and snippets.

@ReaganS94
Last active October 28, 2025 14:55
Show Gist options
  • Select an option

  • Save ReaganS94/50a49de1f95ab35f658d0a7569c43bb7 to your computer and use it in GitHub Desktop.

Select an option

Save ReaganS94/50a49de1f95ab35f658d0a7569c43bb7 to your computer and use it in GitHub Desktop.

Auth service complete guide (Express + TypeScript + MongoDB)

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

0. Final target structure

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.ts

1. Environment and config

1.1 install dotenv

Node does not read .env files automatically. Install dotenv and its types:

npm install dotenv
npm install -D @types/dotenv

1.2 create .env.development.local

In 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=8080

What 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

1.3 src/config/index.ts

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;

1.4 dotenv must load before config is used

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}`);
});

2. Database connection

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);
}

3. Request validation with Zod

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';

4. Mongoose models

We need two collections: User and RefreshToken.

4.1 User model

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;

4.2 RefreshToken model

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;

4.3 models index

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';

5. Utils for access tokens and refresh tokens

We need helpers to create JWT access tokens and to generate random refresh token strings.

5.1 create and verify JWT access tokens

// 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;
}

5.2 generate refresh tokens

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();
}

5.3 utils index

// src/utils/index.ts
export * from './jwt.ts';
export * from './tokens.ts';

6. Middlewares

We need:

  • validateBodyZod to check req.body using Zod schemas
  • notFoundHandler for 404
  • errorHandler to centralize error responses

6.1 validateBodyZod

// 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();
  };

6.2 notFoundHandler

// src/middlewares/notFoundHandler.ts
import type { RequestHandler } from 'express';

export const notFoundHandler: RequestHandler = (_req, res) => {
  res.status(404).json({ error: 'Route not found' });
};

6.3 errorHandler

// 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 });
};

6.4 middlewares index

// src/middlewares/index.ts
export { notFoundHandler } from './notFoundHandler.ts';
export { errorHandler } from './errorHandler.ts';
export { validateBodyZod } from './validateBodyZod.ts';

7. Controllers

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';

8. Routes

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';

9. How each endpoint works

POST /auth/register

  1. validateBodyZod(registerSchema) runs
  2. controller checks if the email already exists
  3. password is hashed using bcrypt and SALT_ROUNDS
  4. user is created in DB with roles ['user']
  5. access token and refresh token are created
  6. refresh token is stored in Mongo with TTL
  7. both tokens are sent to the browser as httpOnly cookies
  8. 201 response with safe user info (no password)

POST /auth/login

  1. validateBodyZod(loginSchema) runs
  2. controller finds the user by email
  3. bcrypt.compare checks password
  4. if ok, new access token and refresh token are created
  5. refresh token is saved in DB
  6. both cookies are set
  7. 200 response with basic user info

GET /auth/me

  1. reads accessToken cookie
  2. verifies it
  3. if expired, responds 401 and sets WWW-Authenticate: token_expired header so the frontend knows to call refresh
  4. if valid, loads the user and returns profile info

POST /auth/refresh

  1. reads refreshToken cookie
  2. finds it in Mongo
  3. deletes that old refresh token (rotation)
  4. creates new access token and new refresh token
  5. stores the new refresh token in Mongo
  6. sends new cookies

DELETE /auth/logout

  1. looks at refreshToken cookie
  2. removes that token from Mongo so it can never refresh again
  3. clears both cookies on the client
  4. returns 204

10. Frontend integration (browser, different port, cookies)

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.

10.1 CORS backend config

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.

10.2 frontend must send credentials

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.

10.3 cookie settings

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

10.4 do not mix localhost and 127.0.0.1

Use the same host name for both frontend and backend in dev. For example:

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.

11. Final checklist

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!

Appendix: glossary / concepts

access token

A short-lived JWT that proves "this user is authenticated."

  • Lives ~15 minutes.
  • Signed using ACCESS_JWT_SECRET.
  • Stored in an httpOnly cookie called accessToken.
  • Contains userId and roles, so the server doesn't have to hit the DB on every request just to know "who is this?"
  • Used for things like /auth/me.

The server does not store access tokens in the database. If someone steals it, it's valid until it expires. That's why it's short-lived.


refresh token

A long-lived opaque random string.

  • Not a JWT.

  • Stored in:

    • browser cookie (refreshToken)
    • database (RefreshToken collection)
  • Has a TTL in Mongo (auto-expires).

  • Used to ask for new access tokens when the access token expires.

If someone steals a refresh token, they can mint new access tokens. To reduce damage:

  • we store it httpOnly (frontend JS cannot read it)
  • we rotate it on every refresh (old one becomes invalid)

refresh token rotation

When the client calls /auth/refresh:

  1. We look up the provided refresh token in Mongo.

  2. If it exists and hasn’t expired:

    • we delete it
    • we create a brand new refresh token and save that
    • we send the new one back in a cookie
    • we also send a new access token

Why: if an attacker steals an old refresh token and tries to reuse it, it's already deleted.


JWT (JSON Web Token)

A signed string that encodes some data.

In this app we use it only for access tokens.

  • We sign it with ACCESS_JWT_SECRET.
  • We set an expiry (15 minutes).
  • We can verify it later using the same secret.
  • If the token is expired or tampered with, jwt.verify will throw.

We do not store JWTs in the DB. They are stateless.


ACCESS_JWT_SECRET

A long random string (64+ chars) from the environment.

  • Used by jsonwebtoken to create and verify JWT access tokens.
  • If someone knows this secret, they can forge tokens and log in as anyone.
  • This is why it's in .env, not hardcoded.

The config file validates its length and will kill the server if it's missing or too short.


SALT_ROUNDS (bcrypt cost factor)

This number controls how expensive hashing is.

  • Higher = more secure but slower.
  • We pass it to bcrypt.hash(password, SALT_ROUNDS).

We NEVER save the raw password in Mongo. Only the bcrypt hash.


bcrypt hashing

When a user registers:

  • We hash the password with bcrypt before storing it in the User document.

When a user logs in:

  • We run bcrypt.compare(plainTextPassword, hashedPasswordFromDB).
  • If they match, password is correct.

You never "decrypt" a hash. Hashing is one-way.


Mongo TTL index (expireAt + expires)

In RefreshToken schema we have:

expireAt: {
  type: Date,
  default: () => new Date(Date.now() + REFRESH_TOKEN_TTL * 1000),
  expires: REFRESH_TOKEN_TTL
}

This means:

  1. When we create the refresh token, we set expireAt to "now + TTL".
  2. The expires option tells MongoDB: automatically delete this document once it's older than that TTL.

So refresh tokens clean themselves up from the DB. You don't need a cron job.

Note: Mongo runs this cleanup on an interval, not instantly. That's normal.


httpOnly cookies

When we do:

res.cookie('accessToken', accessToken, {
  httpOnly: true,
  ...
});
  • httpOnly: true means frontend JavaScript cannot read this cookie with document.cookie.
  • That blocks most XSS attempts from stealing your tokens.
  • The browser will still attach the cookie automatically to requests to your backend.

secure cookie option

Cookie option:

secure: false
  • secure: true means: "Only send this cookie over HTTPS".
  • On localhost (http://localhost) if you set secure: true, the browser will ignore the cookie.
  • So in dev, secure: false is correct.
  • In production (real HTTPS domain), you MUST change that to secure: true.

sameSite cookie option

Cookie option:

sameSite: 'strict'

Controls whether the browser will send the cookie with cross-site requests.

In dev:

  • Frontend on http://localhost:5173
  • Backend on http://localhost:3000

Browsers still treat that as the "same site" (because same site = same registrable domain, and localhost is treated as that), so 'strict' works here.

In production:

  • If frontend and backend are on different domains or subdomains, 'strict' will block cookies.

  • Then you typically need:

    • sameSite: 'none'
    • secure: true

If you forget that in prod, auth will mysteriously "not work" in the browser even though Postman is fine.


credentials: 'include' / withCredentials: true

By default, browsers do NOT send cookies to another origin.

You must explicitly enable it in frontend code.

Example with fetch:

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 this, the request won't send the accessToken / refreshToken cookies. Backend will say 401 "Not authenticated," and you'll think auth is broken.


CORS config

In app.ts:

app.use(
  cors({
    origin: CLIENT_BASE_URL,
    credentials: true,
    exposedHeaders: ['WWW-Authenticate']
  })
);
  • origin: which frontend is allowed. Must match exactly, including protocol and port.
  • credentials: true: tells the browser "yes, you're allowed to include cookies on this request".
  • exposedHeaders: allows frontend JS to read custom headers, specifically WWW-Authenticate.

If the origin doesn't match what the browser is actually running on, the browser blocks the request before Express even sees it.


WWW-Authenticate: token_expired

In /auth/me, if the access token is expired we send:

res.setHeader('WWW-Authenticate', 'token_expired');
res.status(401).json({ error: 'Access token expired' });

Why:

  • Frontend can check: status is 401 AND header is token_expired.
  • That means: "The session is fine, you just need to refresh tokens." So the frontend can call /auth/refresh automatically instead of logging the user out.

This gives you seamless auto-refresh UX.


validateBodyZod middleware

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;
next();
  • Runs before controller logic.
  • If body is invalid, we stop immediately, return 400, and never run the controller.
  • If it's valid, we overwrite req.body with the parsed data. That means inside the controller you can assume req.body is the correct shape and types.

This prevents you from having to do manual if (!email || !password) checks in every controller.


errorHandler middleware

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 });
};
  • This is the last middleware in app.ts.
  • Any throw err or next(err) in a controller ends up here.
  • If we attach err.statusCode = 401 in the controller, that status is used.
  • Result: consistent JSON error responses everywhere.

Without this, your controllers fill up with try { ... } catch(e) { res.status(500).json(...) } noise.


notFoundHandler middleware

export const notFoundHandler: RequestHandler = (_req, res) => {
  res.status(404).json({ error: 'Route not found' });
};

And in app.ts:

app.use('*', notFoundHandler);

Anything that doesn't match /auth/... returns a clean JSON 404 instead of Express's default HTML.


lean()

When you run:

const user = await User.findById(decoded.userId).lean();

.lean() returns a plain JavaScript object instead of a full Mongoose document.

  • It's a little faster.
  • It's safer to send to the client.
  • It avoids accidentally sending Mongoose internals.

We still manually pick what we send back (id, email, roles, etc.). We never send password.


index.ts re-exports (a.k.a. barrel files)

We created tiny index.ts files like:

// src/models/index.ts
export { default as User } from './User';
export { default as RefreshToken } from './RefreshToken';

and:

// src/routes/index.ts
export { default as authRouter } from './auth.route';

and:

// src/controllers/index.ts
export { register, login, refresh, logout, me } from './auth.controller';

This lets us import using path aliases like #models or #routes instead of long relative paths like ../../models/User.

Cleaner imports = cleaner code.


why we import 'dotenv/config' at the top of app.ts

At the top of src/app.ts we do:

import 'dotenv/config';

That loads .env.development.local and populates process.env before anything else imports #config.

If you try to validate env variables before dotenv runs, #config will read undefined values, validation will fail, and the app will exit immediately.

Conclusion: dotenv first, then the rest of the app.


why both /auth/register and /auth/login set cookies

Both register and login:

  1. hash/check password
  2. generate accessToken
  3. generate refreshToken
  4. store the refresh token in Mongo
  5. send both as httpOnly cookies

Why this is good UX:

  • After register, you're instantly logged in. You don't need to manually log in.
  • After login, the browser already has both cookies, so the app can immediately call /auth/me.

why we don't store confirmPassword

confirmPassword exists only to force the user to type the password twice and catch typos.

We validate password === confirmPassword in the registerSchema.

We do NOT:

  • save confirmPassword in Mongo,
  • send confirmPassword back in responses,
  • keep it after the request.

It's thrown away as soon as validation passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment