Express 4 + strict TypeScript boilerplate with JWT auth (refresh-token rotation and reuse detection), zod validation, a typed error envelope, rate limiting and structured logs — plus a full example resource wired end to end.
Versioned under /api/v1. All requests get a correlation id; all errors share one typed envelope; credential routes sit behind a stricter rate limit.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | Public | Liveness probe — unversioned, never rate-limited |
| POST | /api/v1/auth/register | Public | Create account → 201 with user + token pair |
| POST | /api/v1/auth/login | Public | Credentials → token pair (timing-safe on unknown emails) |
| POST | /api/v1/auth/refresh | Public | Rotate a single-use refresh token → new pair |
| POST | /api/v1/auth/logout | Public | Revoke a refresh token server-side → 204 |
| GET | /api/v1/auth/me | Bearer | Current user profile |
| GET | /api/v1/projects | Bearer | List own projects — pagination, status filter, sorting |
| POST | /api/v1/projects | Bearer | Create a project → 201 |
| GET | /api/v1/projects/:id | Bearer | Fetch one — someone else's id looks like 404, never 403 |
| PATCH | /api/v1/projects/:id | Bearer | Partial update — name, description, status |
| DELETE | /api/v1/projects/:id | Bearer | Delete own project → 204 |
Refresh tokens are single-use. Every refresh consumes the old token and issues a new pair — and replaying a consumed token revokes every session for that user.
Each refresh token carries a unique jti tracked server-side. Refreshing consumes it and issues a fresh pair — a leaked old token is worthless.
Presenting an already-consumed refresh token nukes every active session for that account. The real user logs in again; the attacker holds dead tokens.
bcrypt (cost 12) for password hashes, and unknown emails burn the same hash time as wrong passwords — response timing never leaks who has an account.
Real requests against the running starter, and a real file from the package — not pseudocode.
# register an account — returns the user and a token pair curl -X POST http://localhost:3000/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "password": "correct-horse-battery", "name": "Ada Lovelace" }' # then hit a protected endpoint with the accessToken curl http://localhost:3000/api/v1/projects \ -H "Authorization: Bearer <accessToken>"
{ "user": { "id": "7a1e2c9b-4f6d-4b8a-b3e1-0c5d9f2a6e83", "email": "ada@example.com", "name": "Ada Lovelace", "createdAt": "2026-07-10T09:14:31.882Z" }, "tokens": { "tokenType": "Bearer", "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…", "accessTokenExpiresInSeconds": 900, "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…", "refreshTokenExpiresInSeconds": 2592000 } }
// POST /api/v1/auth/register with a bad email + short password // → HTTP 422, every field issue in one round trip { "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ { "location": "body", "path": "email", "message": "A valid email address is required" }, { "location": "body", "path": "password", "message": "Password must be at least 10 characters" } ] }, "requestId": "9f2c6d1e-8b4a-4f0e-9d3c-2a7b5e8c1f4d" }
// src/middleware/validate.ts — shipped verbatim in the package export function validate(schemas: ValidationSchemas) { return (req: Request, _res: Response, next: NextFunction): void => { const issues: ValidationIssue[] = []; for (const location of LOCATIONS) { const schema = schemas[location]; if (!schema) continue; const result = schema.safeParse(req[location]); if (result.success) { // replace raw input with parsed output: // coerced numbers, trimmed strings, defaults applied req[location] = result.data; } else { for (const issue of result.error.issues) { issues.push({ location, path: issue.path.join("."), message: issue.message }); } } } if (issues.length > 0) { next(AppError.validation(issues)); return; } next(); }; }
Controllers never touch storage; services never touch req/res. Adding a resource is four files and one mount line — the projects resource is the worked template.
routes/projects.routes.ts — paths, auth guard, middleware wiringrequireAuth verifies the Bearer token, attaches req.uservalidate({ body, query, params }) — zod parses, coerces and defaults the inputsrc/ ├── app.ts express assembly, middleware order ├── server.ts boot + graceful shutdown ├── config.ts zod-validated env config ├── lib/ │ ├── errors.ts AppError + typed codes │ ├── jwt.ts sign/verify access + refresh │ ├── logger.ts JSON-lines structured logs │ └── async-handler.ts ├── middleware/ │ ├── auth.ts requireAuth bearer guard │ ├── validate.ts zod body/query/params │ ├── error.ts 404 + central handler │ ├── rate-limit.ts fixed-window limiter │ └── request-logger.ts ├── routes/ auth · projects · index ├── controllers/ HTTP layer ├── services/ business rules + stores ├── schemas/ zod + inferred types └── types/express.d.ts req.user augmentation