TL;DR
- REST principles: Stateless, resource-based URLs, proper HTTP methods & status codes.
- JWT auth: Access token (short-lived) + Refresh token (long-lived, httpOnly cookie).
- Security essentials: Input validation, rate limiting, CORS, helmet, parameterized queries.
- Middleware pattern: Request → auth → validate → controller → response.
Step 1: RESTful API Design
REST (Representational State Transfer) was defined by Roy Fielding in his 2000 PhD dissertation as a set of constraints for building scalable web services. It became the dominant API style because it maps perfectly onto HTTP: resources are nouns (URLs), actions are verbs (GET/POST/PUT/DELETE), and responses use standard status codes. Proper REST design makes APIs intuitive (developers can guess endpoints), cacheable (GET responses are safe to cache), and tooling-friendly (Swagger, Postman). Getting URL design and status codes right is tested in almost every backend interview.
URL Design
✅ Good REST URLs:
GET /api/users → List users
GET /api/users/123 → Get user 123
POST /api/users → Create user
PUT /api/users/123 → Replace user 123
PATCH /api/users/123 → Partial update
DELETE /api/users/123 → Delete user 123
GET /api/users/123/orders → User's orders (nested resource)
GET /api/orders?status=pending&page=2 → Filtered list
❌ Bad URLs:
GET /api/getUsers
POST /api/createUser
GET /api/users/delete/123
POST /api/users/123/updateName
HTTP Status Codes
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (return created resource) |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid input / validation error |
| 401 | Unauthorized | No auth token or expired |
| 403 | Forbidden | Authenticated but no permission |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate entry (unique constraint) |
| 422 | Unprocessable | Valid syntax but semantic error |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server Error | Unexpected bug |
Response Format
// Success response
{
"data": { "id": 1, "name": "Alice", "email": "alice@test.com" },
"meta": { "requestId": "abc-123" }
}
// List response with pagination
{
"data": [{ ... }, { ... }],
"meta": {
"total": 150,
"page": 2,
"perPage": 20,
"nextCursor": "eyJpZCI6NDJ9"
}
}
// Error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email format",
"details": [
{ "field": "email", "message": "Must be a valid email address" }
]
}
}
Step 2: JWT Authentication
JWT (JSON Web Tokens) became the standard for stateless API authentication because session-based auth (storing session IDs on the server) breaks when you scale horizontally — a request hitting Server B can't read Server A's session store. JWTs encode user identity into a signed token that any server can verify independently. The dual-token pattern (short-lived access + long-lived refresh) was invented because JWTs can't be revoked once issued — so access tokens expire quickly (15 min), and refresh tokens (stored as httpOnly cookies) are the only thing that touches the database.
Flow
1. Login: POST /api/auth/login { email, password }
→ Verify credentials
→ Return: { accessToken } + Set httpOnly cookie (refreshToken)
2. Protected request: GET /api/users/me
→ Header: Authorization: Bearer <accessToken>
→ Middleware verifies token → allows request
3. Token expired: 401 response
→ Client calls POST /api/auth/refresh (cookie sent automatically)
→ New accessToken returned
4. Logout: POST /api/auth/logout
→ Clear refresh token cookie + invalidate in DB
Implementation
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// Login
router.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
// Short-lived access token (15 min)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Long-lived refresh token (7 days) — httpOnly cookie
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token hash in DB (for revocation)
await db.storeRefreshToken(user.id, refreshToken);
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
// Auth middleware
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // { userId, role }
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Role-based authorization
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Protected route
router.get('/admin/users', authenticate, authorize('admin'), async (req, res) => {
const users = await db.getAllUsers();
res.json({ data: users });
});
Step 3: Input Validation (Zod)
Input validation exists because you cannot trust any data that comes from outside your system — every HTTP request body, query parameter, and header is potentially malicious or malformed. Without validation, invalid data silently corrupts your database or, worse, enables SQL injection, XSS, and other attacks. Zod became the go-to validation library in the TypeScript ecosystem because it provides runtime validation with automatic type inference — your TypeScript types and runtime checks stay in sync with zero duplication. It replaced Joi and Yup for most new projects.
const { z } = require('zod');
// Define schema
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Must contain uppercase, lowercase, and number'
),
age: z.number().int().min(13).max(120).optional(),
});
// Validation middleware factory
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
details: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
},
});
}
req.body = result.data; // Use validated & typed data
next();
};
}
// Usage
router.post('/users', validate(createUserSchema), async (req, res) => {
// req.body is guaranteed valid here
const user = await userService.create(req.body);
res.status(201).json({ data: user });
});
Step 4: Security Best Practices
These security practices exist because web APIs are the #1 attack surface for modern applications. Every OWASP Top 10 vulnerability (injection, broken auth, misconfiguration) applies directly to your Express/Node server. Helmet sets security headers that browsers enforce (CSP, X-Frame-Options). Rate limiting prevents brute-force attacks and DDoS. CORS prevents unauthorized domains from calling your API. Parameterized queries prevent SQL injection — the most dangerous and most common web vulnerability. These aren't optional; they're baseline requirements for any production API.
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const app = express();
// 1. Helmet — security headers
app.use(helmet());
// 2. CORS — restrict origins
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
credentials: true, // Allow cookies
}));
// 3. Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, try again later' },
});
app.use('/api/', limiter);
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 min
});
app.use('/api/auth/login', authLimiter);
// 4. Body size limit
app.use(express.json({ limit: '10kb' })); // Prevent huge payloads
// 5. Parameterized queries (NEVER concatenate user input)
// ❌ SQL Injection vulnerable
const query = `SELECT * FROM users WHERE email = '${email}'`;
// ✅ Safe — parameterized
const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
// 6. Don't expose sensitive data
const userResponse = {
id: user.id,
name: user.name,
email: user.email,
// ❌ NEVER: passwordHash, internalId, refreshToken
};
Step 5: Middleware Pattern
Middleware is Express's core architectural pattern — borrowed from Ruby's Rack and Python's WSGI. It was designed as a pipeline: each function in the chain can inspect the request, modify it, respond, or pass to the next. This composable approach means authentication, logging, rate limiting, and validation are all independent, reusable modules that snap together in any order. Understanding middleware execution order and the next() function is fundamental to Express development and comes up in every Node.js interview.
// Middleware execution order
app.use(helmet()); // 1. Security headers
app.use(cors(config)); // 2. CORS
app.use(express.json()); // 3. Parse body
app.use(requestId()); // 4. Attach unique ID
app.use(logger()); // 5. Log request
app.use(rateLimit()); // 6. Rate limit
// Route-specific middleware chain
router.post('/posts',
authenticate, // 7. Verify JWT
authorize('author'), // 8. Check role
validate(postSchema), // 9. Validate body
postController.create // 10. Handle request
);
app.use(errorHandler); // 11. Catch all errors
// Custom middleware example: request timing
function requestTimer(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.url} — ${res.statusCode} — ${duration}ms`);
});
next();
}
Step 6: Graceful Shutdown
Graceful shutdown exists because abruptly killing a Node.js process (SIGKILL, process crash) leaves requests half-processed, database connections dangling, and transactions uncommitted. When deploying new code (Kubernetes rolling update, PM2 reload), the old process receives SIGTERM and needs to: (1) stop accepting new connections, (2) finish in-flight requests, (3) close database/Redis connections cleanly, (4) then exit. Without this, deployments cause brief error spikes for users and potential data corruption. Every production Node.js app needs this pattern.
const server = app.listen(3000);
// Handle shutdown signals
const signals = ['SIGTERM', 'SIGINT'];
signals.forEach(signal => {
process.on(signal, async () => {
console.log(`${signal} received — starting graceful shutdown`);
// 1. Stop accepting new connections
server.close(() => {
console.log('HTTP server closed');
});
// 2. Close database connections
await db.end();
console.log('Database connections closed');
// 3. Close Redis
await redis.quit();
console.log('Redis connection closed');
// 4. Exit
process.exit(0);
});
});
// Force kill after timeout
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
Interview Questions
-
How do you handle authentication in a stateless API?
- JWT tokens. Access token (short-lived, in Authorization header) for API calls. Refresh token (long-lived, httpOnly cookie) for getting new access tokens. Store refresh token hash in DB for revocation.
-
What's the difference between 401 and 403?
- 401 Unauthorized: no valid credentials provided (need to login). 403 Forbidden: credentials are valid but user doesn't have permission for this action.
-
How do you prevent SQL injection?
- ALWAYS use parameterized queries (prepared statements). Never concatenate user input into SQL strings. Use ORMs with parameter binding. Validate and sanitize all inputs.
-
Explain middleware in Express.
- Functions with
(req, res, next)signature. Called in order of registration. Each can modify request/response, end the chain, or callnext()to pass to the next middleware. Used for auth, validation, logging, error handling.
- Functions with