The Cost of Getting Auth Wrong
- JWT authentication — stateless, scalable, perfect for APIs
- Session-based auth — stateful, perfect for web apps
- OAuth2 — let someone else handle the hard parts
- RBAC — role-based access control as middleware
JWT Authentication From Scratch
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
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 Auth With Redis
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
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
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.
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!
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