Backend Starter
Backend Starter
This is a clean, production-ready backend starter using Express, TypeScript, and PostgreSQL. It covers the essentials: project setup, environment validation, database with Prisma, modern JWT authentication with session rotation, and secure defaults.
Tech stack
- Runtime/package manager: Bun
- Server: Express 5 + TypeScript
- Database: PostgreSQL 16 (Docker)
- ORM + migrations: Prisma
- Validation: Zod
- Auth:
joseJWT + refresh-session rotation in DB - Security: Helmet, CORS allowlist, rate limiting, HTTP-only cookies
- Logging: Pino
- Testing: Vitest + Supertest
1) Bootstrap project
mkdir express-ts-pg-backend && cd express-ts-pg-backend
bun init -y
bun add express zod cors helmet cookie-parser express-rate-limit pino pino-http argon2 jose dotenv @prisma/client
bun add -d typescript tsx prisma @types/node @types/express @types/cookie-parser vitest supertest @types/supertest
bunx tsc --init --rootDir src --outDir dist --module nodenext --moduleResolution nodenext --target ES2022
package.json scripts:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "vitest run",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate"
}
}
Suggested structure:
src/
app.ts
server.ts
config/
env.ts
lib/
prisma.ts
logger.ts
tokens.ts
features/
auth/
routes.ts
schema.ts
middlewares/
error-handler.ts
not-found.ts
2) Environment validation (non-negotiable)
Create .env.example:
NODE_ENV=development
PORT=4000
CORS_ORIGIN=http://localhost:3000
DATABASE_URL=postgresql://app_user:app_password@localhost:5432/app_db?schema=public
JWT_ACCESS_SECRET=replace-with-64-char-secret
JWT_REFRESH_SECRET=replace-with-64-char-secret
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=30d
REFRESH_COOKIE_NAME=rt
src/config/env.ts:
import 'dotenv/config';
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
CORS_ORIGIN: z.string().min(1),
DATABASE_URL: z.string().url(),
JWT_ACCESS_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
ACCESS_TOKEN_TTL: z.string().default('15m'),
REFRESH_TOKEN_TTL: z.string().default('30d'),
REFRESH_COOKIE_NAME: z.string().default('rt'),
});
export const env = envSchema.parse(process.env);
If env is invalid, the app fails fast on boot.
3) PostgreSQL with Docker Compose
docker-compose.yml:
services:
db:
image: postgres:16-alpine
container_name: express-ts-pg-db
restart: unless-stopped
environment:
POSTGRES_DB: app_db
POSTGRES_USER: app_user
POSTGRES_PASSWORD: app_password
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app_user -d app_db']
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
Run DB:
docker compose up -d
4) Prisma schema + migrations
Initialize Prisma:
bunx prisma init --datasource-provider postgresql
prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
USER
ADMIN
}
model User {
id String @id @default(uuid())
fullName String
email String @unique
passwordHash String
role UserRole @default(USER)
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Session {
id String @id @default(cuid())
userId String
jti String @unique
userAgent String?
ip String?
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
@@index([expiresAt])
}
Apply migration:
bunx prisma migrate dev --name init_auth
src/lib/prisma.ts:
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
5) App foundation (Express 5)
src/lib/logger.ts:
import pino from 'pino';
export const logger = pino({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
});
src/app.ts:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import pinoHttp from 'pino-http';
import { env } from './config/env';
import { logger } from './lib/logger';
import { authRouter } from './features/auth/routes';
import { notFoundHandler } from './middlewares/not-found';
import { errorHandler } from './middlewares/error-handler';
const app = express();
const originList = env.CORS_ORIGIN.split(',').map((s) => s.trim());
app.use(
pinoHttp({
logger,
redact: ['req.headers.authorization', 'req.headers.cookie'],
}),
);
app.use(helmet());
app.use(
cors({
origin: (origin, cb) => {
if (!origin || originList.includes(origin)) return cb(null, true);
return cb(new Error('CORS blocked'));
},
credentials: true,
}),
);
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use(
rateLimit({
windowMs: 60_000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
}),
);
app.get('/health', (_req, res) => {
res.status(200).json({ ok: true });
});
app.use('/v1/auth', authRouter);
app.use(notFoundHandler);
app.use(errorHandler);
export { app };
src/server.ts:
import { app } from './app';
import { env } from './config/env';
import { logger } from './lib/logger';
app.listen(env.PORT, () => {
logger.info(`API listening on http://localhost:${env.PORT}`);
});
src/middlewares/error-handler.ts:
import type { NextFunction, Request, Response } from 'express';
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
const message = err instanceof Error ? err.message : 'Internal server error';
res.status(500).json({ message });
}
src/middlewares/not-found.ts:
import type { Request, Response } from 'express';
export function notFoundHandler(_req: Request, res: Response) {
res.status(404).json({ message: 'Route not found' });
}
6) Modern JWT helpers with jose
src/lib/tokens.ts:
import { JWTPayload, SignJWT, jwtVerify } from 'jose';
import { env } from '../config/env';
const accessSecret = new TextEncoder().encode(env.JWT_ACCESS_SECRET);
const refreshSecret = new TextEncoder().encode(env.JWT_REFRESH_SECRET);
export async function signAccessToken(payload: JWTPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(env.ACCESS_TOKEN_TTL)
.sign(accessSecret);
}
export async function signRefreshToken(payload: JWTPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(env.REFRESH_TOKEN_TTL)
.sign(refreshSecret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, accessSecret);
return payload;
}
export async function verifyRefreshToken(token: string) {
const { payload } = await jwtVerify(token, refreshSecret);
return payload;
}
7) Auth routes (signup, login, refresh, logout, me)
src/features/auth/schema.ts:
import { z } from 'zod';
export const signUpSchema = z.object({
fullName: z.string().min(3).max(80),
email: z.string().email(),
password: z
.string()
.min(10)
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[a-z]/, 'Must contain a lowercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a symbol'),
});
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
src/features/auth/routes.ts:
import { Router, type Request, type Response } from 'express';
import argon2 from 'argon2';
import { randomUUID } from 'node:crypto';
import { prisma } from '../../lib/prisma';
import { loginSchema, signUpSchema } from './schema';
import { env } from '../../config/env';
import {
signAccessToken,
signRefreshToken,
verifyAccessToken,
verifyRefreshToken,
} from '../../lib/tokens';
const authRouter = Router();
function setRefreshCookie(res: Response, token: string) {
res.cookie(env.REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/v1/auth',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
function clearRefreshCookie(res: Response) {
res.clearCookie(env.REFRESH_COOKIE_NAME, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/v1/auth',
});
}
authRouter.post('/signup', async (req, res) => {
const parsed = signUpSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
const { fullName, email, password } = parsed.data;
const exists = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (exists) return res.status(409).json({ message: 'Email already in use' });
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const user = await prisma.user.create({
data: { fullName, email: email.toLowerCase(), passwordHash },
select: { id: true, email: true, fullName: true, role: true },
});
const jti = randomUUID();
const refreshToken = await signRefreshToken({ sub: user.id, jti });
const accessToken = await signAccessToken({ sub: user.id, role: user.role });
await prisma.session.create({
data: {
userId: user.id,
jti,
userAgent: req.headers['user-agent'] ?? null,
ip: req.ip,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
setRefreshCookie(res, refreshToken);
return res.status(201).json({ accessToken, user });
});
authRouter.post('/login', async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) return res.status(401).json({ message: 'Invalid credentials' });
const ok = await argon2.verify(user.passwordHash, password);
if (!ok) return res.status(401).json({ message: 'Invalid credentials' });
const jti = randomUUID();
const refreshToken = await signRefreshToken({ sub: user.id, jti });
const accessToken = await signAccessToken({ sub: user.id, role: user.role });
await prisma.session.create({
data: {
userId: user.id,
jti,
userAgent: req.headers['user-agent'] ?? null,
ip: req.ip,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
setRefreshCookie(res, refreshToken);
return res.status(200).json({
accessToken,
user: { id: user.id, email: user.email, fullName: user.fullName, role: user.role },
});
});
authRouter.post('/refresh', async (req, res) => {
const token = req.cookies[env.REFRESH_COOKIE_NAME];
if (!token) return res.status(401).json({ message: 'Missing refresh token' });
const payload = await verifyRefreshToken(token);
const jti = String(payload.jti ?? '');
const userId = String(payload.sub ?? '');
if (!jti || !userId) return res.status(401).json({ message: 'Invalid refresh token' });
const session = await prisma.session.findFirst({
where: {
jti,
userId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
});
if (!session) return res.status(401).json({ message: 'Session expired or revoked' });
const nextJti = randomUUID();
const [user] = await prisma.$transaction([
prisma.user.findUniqueOrThrow({ where: { id: userId } }),
prisma.session.update({ where: { jti }, data: { revokedAt: new Date() } }),
prisma.session.create({
data: {
userId,
jti: nextJti,
userAgent: req.headers['user-agent'] ?? null,
ip: req.ip,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}),
]);
const nextRefresh = await signRefreshToken({ sub: user.id, jti: nextJti });
const nextAccess = await signAccessToken({ sub: user.id, role: user.role });
setRefreshCookie(res, nextRefresh);
return res.status(200).json({ accessToken: nextAccess });
});
authRouter.post('/logout', async (req, res) => {
const token = req.cookies[env.REFRESH_COOKIE_NAME];
if (token) {
try {
const payload = await verifyRefreshToken(token);
const jti = String(payload.jti ?? '');
if (jti) await prisma.session.updateMany({ where: { jti }, data: { revokedAt: new Date() } });
} catch {
// Invalid token: still clear cookie and return 204.
}
}
clearRefreshCookie(res);
return res.status(204).send();
});
authRouter.get('/me', async (req: Request, res: Response) => {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) return res.status(401).json({ message: 'Unauthorized' });
const token = auth.slice('Bearer '.length);
const payload = await verifyAccessToken(token);
const userId = String(payload.sub ?? '');
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, fullName: true, role: true, createdAt: true },
});
if (!user) return res.status(404).json({ message: 'User not found' });
return res.status(200).json({ user });
});
export { authRouter };
For very high-security apps, use opaque refresh tokens (random bytes + DB hash) instead of JWT refresh tokens.
8) Minimal API test
src/features/auth/auth.test.ts:
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { app } from '../../app';
describe('health', () => {
it('returns OK', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
});
Run tests:
bun run test
9) Production hardening checklist
- Use HTTPS everywhere.
- Set
secure: truecookies in production. - Rotate JWT secrets regularly.
- Add per-route auth rate limits (
/login,/refresh). - Add request ID + structured logs + central log shipping.
- Add DB backups and migration strategy.
- Add OpenAPI docs and contract tests.
- Add CI: lint, typecheck, test, migration check.
10) Quick run
# 1) start db
docker compose up -d
# 2) run migrations
bunx prisma migrate dev --name init_auth
# 3) start api
bun run devComments (0)
Loading comments...