Authentication and Security in Express APIs

Front
Back
Right
Left
Top
Bottom
THE COST

The Cost of Getting Auth Wrong

Authentication bugs aren’t just technical debt — they’re liability. A single misconfigured JWT, an unprotected admin route, or a missing rate limit is all it takes.

I’ve reviewed hundreds of Express codebases over the years. Authentication is the area I find the most issues, and the most creative solutions. This post is everything I wish I’d known when I shipped my first production auth system.

We’ll cover four patterns, each suited to different situations:
JWT AUTH

JWT Authentication From Scratch

JSON Web Tokens are the most common auth mechanism for REST APIs. A client logs in, receives a signed token, and sends it on every subsequent request.
💻
npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs

Login — Issue Tokens

📘
// src/controllers/auth.controller.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { db } from '../config/database.js';
import { AppError } from '../middleware/error.js';

const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = '15m';     // Short-lived access token
const REFRESH_EXPIRES_IN = '7d';  // Long-lived refresh token

export async function login(req: Request, res: Response) {
  const { email, password } = req.body;

  const user = await db.user.findUnique({ where: { email } });
  if (!user) throw new AppError('Invalid credentials', 401);

  const isValid = await bcrypt.compare(password, user.passwordHash);
  if (!isValid) throw new AppError('Invalid credentials', 401);

  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    JWT_SECRET,
    { expiresIn: JWT_EXPIRES_IN }
  );

  const refreshToken = jwt.sign(
    { userId: user.id },
    process.env.REFRESH_TOKEN_SECRET!,
    { expiresIn: REFRESH_EXPIRES_IN }
  );

  // Store refresh token securely in httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken });
}
Security Note
Never store tokens in `localStorage`. Use `httpOnly` cookies for refresh tokens — they’re inaccessible to JavaScript, protecting against XSS attacks.

Token Verification Middleware

📘
// src/middleware/auth.ts
import jwt, { JwtPayload } from 'jsonwebtoken';

interface AuthRequest extends Request {
  user?: JwtPayload;
}

export function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
    req.user = payload;
    next();
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    res.status(401).json({ error: 'Invalid token' });
  }
}

Token Refresh

📘
// src/routes/auth.routes.ts
router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as JwtPayload;

    const newAccessToken = jwt.sign(
      { userId: payload.userId, role: payload.role },
      process.env.JWT_SECRET!,
      { expiresIn: '15m' }
    );

    res.json({ accessToken: newAccessToken });
  } catch {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});
SESSION-BASED
Critical for Express

Session-Based Auth With Redis

For traditional web apps (server-rendered, cookie-based), sessions are often simpler and more secure than JWTs.
💻
npm install express-session connect-redis redis
npm install --save-dev @types/express-session
📘
// src/config/session.ts
import session from 'express-session';
import { createClient } from 'redis';
import { RedisStore } from 'connect-redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

export const sessionMiddleware = session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
    sameSite: 'strict'
  }
});
📘
// src/index.ts
import { sessionMiddleware } from './config/session.js';
app.use(sessionMiddleware);
Login with sessions:
📘
export async function login(req: Request, res: Response) {
  // ... validate credentials ...
  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: 'Logged in' });
}

export function requireSession(req: Request, res: Response, next: NextFunction) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  next();
}
OAUTH2
Delegate Authentication

OAuth2

For “Login with Google/GitHub”, you don’t need to build auth from scratch. OAuth2 lets you delegate identity verification to a trusted provider. The simplest approach in 2027: Use the provider’s official SDK rather than Passport.js for new projects.
Google OAuth2 Example (raw HTTP flow)
📘
// src/routes/auth.routes.ts

// Step 1: Redirect user to Google
router.get('/google', (req, res) => {
  const params = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID!,
    redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
    response_type: 'code',
    scope: 'openid email profile',
  });

  res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});

// Step 2: Google redirects back with a code
router.get('/google/callback', async (req, res) => {
  const { code } = req.query;

  // Exchange code for tokens
  const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      redirect_uri: process.env.GOOGLE_REDIRECT_URI,
      grant_type: 'authorization_code',
    }),
  });

  const tokens = await tokenRes.json();

  // Get user profile
  const profileRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
    headers: { Authorization: `Bearer ${tokens.access_token}` }
  });

  const profile = await profileRes.json();

  // Find or create user in your DB, issue your own JWT
  const user = await db.user.upsert({
    where: { googleId: profile.sub },
    update: { email: profile.email },
    create: { googleId: profile.sub, email: profile.email, name: profile.name }
  });

  const accessToken = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, { expiresIn: '15m' });
  res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`);
});
Role-Based Access Control (RBAC)

RBAC lets you restrict routes by user role — admin, editor, viewer. The clean way to do this in Express is with a factory middleware function:

📘
// src/middleware/rbac.ts
export function requireRole(...roles: string[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}
📘
import { requireAuth } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';

// Only admins can delete users
router.delete('/users/:id', requireAuth, requireRole('admin'), deleteUser);

// Admins and editors can update posts
router.put('/posts/:id', requireAuth, requireRole('admin', 'editor'), updatePost);

// Any authenticated user can view their profile
router.get('/profile', requireAuth, getProfile);
Security Hardening Checklist
 
Beyond auth logic, here are the security layers every Express API needs:
📘
app.use(helmet({
  contentSecurityPolicy: true,
  hsts: { maxAge: 31536000 }
}));

// 2. Strict CORS — never use origin: '*' in production
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(','),
  credentials: true
}));

// 3. Rate limiting — separate limits for auth vs API
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10, // Only 10 login attempts per 15 min
  message: { error: 'Too many attempts. Try again later.' }
});

app.use('/api/auth', authLimiter);
app.use('/api', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));

The only truly secure system is one that is powered off, cast in a block of concrete and sealed in a lead-lined room with armed guards.

Gene Spafford, Professor of Computer Science

Thank You for Spending Your Valuable Time

I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Front
Back
Right
Left
Top
Bottom
FAQ's

Frequently Asked Questions

`findUnique` only works on `@id` or `@unique` fields and generates a single indexed lookup in MySQL. `findFirst` works on any field using a table scan with `WHERE`. Always prefer `findUnique` for performance when the field supports it.

Not directly — Prisma uses `INSERT IGNORE` under the hood with `skipDuplicates: true` in MySQL. For true upsert-many, loop `upsert()` calls inside a `$transaction` for atomicity.

`const count = await prisma.user.count({ where: { active: true } });` Prisma generates `SELECT COUNT(*) FROM users WHERE active = 1` — fully optimized for MySQL.

Absolutely. Import the singleton `prisma` instance in any Express middleware. Never create new `PrismaClient` instances — always use the singleton from `src/lib/prisma.ts`.

It closes all MySQL connections in the pool. In long-running Express servers, you typically never call it manually. Call it in scripts, seed files, and serverless functions after execution completes.

Comments are closed