XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksNode.jsREST APIs, Auth & Security

REST APIs, Auth & Security

RESTauthenticationJWTsecuritymiddlewarehandbook

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

  1. 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.
  2. 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.
  3. 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.
  4. Explain middleware in Express.

    • Functions with (req, res, next) signature. Called in order of registration. Each can modify request/response, end the chain, or call next() to pass to the next middleware. Used for auth, validation, logging, error handling.
Event Loop, Streams & Architecture

On this page

  • TL;DR
  • Step 1: RESTful API Design
  • URL Design
  • HTTP Status Codes
  • Response Format
  • Step 2: JWT Authentication
  • Flow
  • Implementation
  • Step 3: Input Validation (Zod)
  • Step 4: Security Best Practices
  • Step 5: Middleware Pattern
  • Step 6: Graceful Shutdown
  • Interview Questions