Security

API Security Checklist for Production Applications

This API security checklist covers the essential protections every production API needs before handling real user traffic. Use it as a pre-launch review or periodic audit reference. Each item includes a brief explanation and, where applicable, a code snippet you can adapt to your stack.

Bookmark this page — you will want it for quick, repeated lookups rather than a single read-through.

Authentication

ItemWhy It Matters
Use short-lived JWTs (15-60 min) with refresh tokensLimits the exposure window if an attacker steals a token
Hash passwords with bcrypt (cost 12+) or Argon2Attackers crack MD5 and SHA-256 hashes in seconds
Enforce minimum password complexityPrevents trivially guessable credentials
Implement rate limiting on login endpointsBlocks brute-force and credential stuffing attacks
Return generic error messages on auth failure“Invalid credentials” — never reveal which field was wrong
Invalidate sessions/tokens on logoutPrevents reuse of stolen tokens after user logs out
Support multi-factor authentication for sensitive operationsAdds a second verification layer beyond passwords
// JWT with short expiration + refresh token pattern
import jwt from 'jsonwebtoken';

function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId: user.id },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

For detailed implementation patterns, see OAuth2, JWT, and session tokens explained and authentication in Express with Passport and JWT.

Authorization

ItemWhy It Matters
Deny access by default on every endpointPrevents accidental exposure of unprotected routes
Verify resource ownership in every queryStops users from accessing other users’ data (IDOR)
Use role-based or attribute-based access controlCentralizes permission logic instead of scattering checks
Separate admin endpoints from public endpointsReduces the attack surface for privileged operations
Never rely on client-side hiding for access controlAttackers bypass UI restrictions trivially
// Ownership check in database queries
async function getOrder(userId, orderId) {
  const order = await Order.findOne({
    _id: orderId,
    userId: userId, // Always scope to the authenticated user
  });

  if (!order) throw new NotFoundError('Order not found');
  return order;
}

// Role-based middleware
function authorize(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

app.delete('/api/admin/users/:id', authenticate, authorize('admin'), deleteUser);

Input Validation

ItemWhy It Matters
Validate all input server-side (never trust the client)Attackers bypass client-side validation in seconds
Use schema validation (Zod, Joi, Pydantic)Catches malformed input before it reaches business logic
Parameterize all database queriesPrevents SQL and NoSQL injection
Validate and sanitize file uploads (type, size, content)Blocks malicious file execution and storage abuse
Reject unexpected fields in request bodiesPrevents mass assignment vulnerabilities
Escape or sanitize HTML in user contentPrevents stored XSS attacks
import { z } from 'zod';

const createUserSchema = z.object({
  email: z.string().email().max(255),
  password: z.string().min(8).max(128),
  name: z.string().min(1).max(100),
}).strict(); // Rejects unexpected fields

app.post('/api/users', async (req, res) => {
  const parsed = createUserSchema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error.issues });
  }

  const user = await createUser(parsed.data);
  res.status(201).json(user);
});

For comprehensive validation patterns, see React Hook Form with Zod validation.

Rate Limiting and Throttling

ItemWhy It Matters
Rate limit authentication endpoints (5-10 req/15 min)Prevents brute-force attacks
Rate limit API endpoints by user/IPPrevents abuse and protects server resources
Return 429 Too Many Requests with Retry-After headerTells clients when they can retry
Implement graduated penalties for repeated violationsEscalates from throttling to temporary bans
Rate limit by API key for third-party consumersPrevents individual consumers from monopolizing resources
import rateLimit from 'express-rate-limit';

// Strict limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'Too many attempts. Try again later.' },
  standardHeaders: true,
  legacyHeaders: false,
});

// General API limit
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
});

app.use('/api/auth/', authLimiter);
app.use('/api/', apiLimiter);

For deeper strategies, see API rate limiting 101 and rate limiting strategies: token bucket, leaky bucket, fixed window.

Security Headers

HeaderValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomainsForces HTTPS for one year
Content-Security-Policydefault-src 'self'Restricts resource loading origins
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing
X-Frame-OptionsDENYPrevents clickjacking via iframes
Referrer-Policystrict-origin-when-cross-originLimits referrer information leakage
Permissions-Policycamera=(), microphone=(), geolocation=()Disables unused browser features
import helmet from 'helmet';

// Helmet sets all recommended security headers
app.use(helmet());

// Or configure individually
app.use(helmet.hsts({ maxAge: 31536000, includeSubDomains: true }));
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", 'data:', 'https:'],
  },
}));

CORS Configuration

ItemWhy It Matters
Specify exact allowed origins (no wildcards with credentials)Prevents cross-origin attacks on authenticated endpoints
List only necessary HTTP methodsReduces exposure to unintended operations
Allowlist required headers explicitlyPrevents unexpected headers from reaching your handlers
Set Access-Control-Max-Age for preflight cachingReduces preflight request overhead
Add Vary: Origin when returning dynamic originsPrevents CDN/cache from serving wrong CORS headers
import cors from 'cors';

const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com'];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}));

Data Protection

ItemWhy It Matters
Enforce HTTPS everywhere (redirect HTTP to HTTPS)Encrypts data in transit
Encrypt sensitive data at rest (AES-256-GCM)Protects data even if an attacker compromises storage
Never log sensitive data (passwords, tokens, PII)Prevents credential exposure in log files
Mask or redact sensitive fields in API responsesReturns only the data the client needs
Store secrets in environment variables or a secrets managerKeeps credentials out of source code
Use HttpOnlySecureSameSite cookie flagsPrevents JavaScript access and cross-site cookie theft
// Redact sensitive fields before returning
function sanitizeUser(user) {
  const { passwordHash, refreshToken, ...safe } = user;
  return safe;
}

// Secure cookie configuration
app.use(session({
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 3600000,
  },
}));

For managing secrets across environments, see secrets management: comparing Vault, AWS KMS, and other tools.

Error Handling

ItemWhy It Matters
Return generic error messages in productionPrevents stack traces from leaking internals
Use consistent error response formatMakes errors predictable for API consumers
Log detailed errors internally for debuggingKeeps diagnostic information available without exposing it to clients
Never expose database errors to clientsPrevents attackers from learning your schema
// Consistent error response format
app.use((err, req, res, next) => {
  console.error({
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    userId: req.user?.id,
  });

  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: statusCode === 500 ? 'Internal server error' : err.message,
    code: err.code || 'UNKNOWN_ERROR',
  });
});

Logging and Monitoring

ItemWhy It Matters
Log all authentication events (success and failure)Detects brute-force attempts and unauthorized access
Log authorization failuresIdentifies privilege escalation attempts
Log request metadata (IP, user agent, timestamp)Provides context for incident investigation
Set up alerts for anomalous patternsCatches attacks in progress before damage spreads
Forward logs to a centralized systemEnables cross-service correlation and long-term retention

For setting up centralized logging, see monitoring and logging microservices with Prometheus and Grafana.

Deployment Hardening

ItemWhy It Matters
Run npm audit / pip-audit in CI pipelineCatches known dependency vulnerabilities before deploy
Keep dependencies updated (Dependabot, Renovate)Patches known vulnerabilities automatically
Use non-root users in Docker containersLimits damage if an attacker compromises the container
Disable debug mode and verbose errors in productionPrevents information leakage
Set database users to least-privilege permissionsLimits SQL injection damage
Enable automated security scanning in CI/CDCatches vulnerabilities before they reach production
# Non-root user in Docker
FROM node:20-slim
RUN addgroup --system app && adduser --system --group app
WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev
USER app
CMD ["node", "server.js"]

Pre-Launch Quick Check

Before deploying a new API to production, verify these critical items:

  1. All endpoints require authentication (unless explicitly public)
  2. Every database query uses parameterized values
  3. All request bodies and query parameters go through input validation
  4. Rate limiting protects authentication and write endpoints
  5. Your server sends security headers (use helmet or equivalent)
  6. CORS specifies exact origins (not wildcards with credentials)
  7. Secrets are in environment variables, not in code
  8. Error responses do not expose stack traces or internal details
  9. Logging captures authentication events and authorization failures
  10. Dependencies have no known critical vulnerabilities

This API security checklist is not exhaustive — advanced topics like API gateway security, mTLS, and request signing apply to specific architectures. However, completing every item on this list addresses the vulnerabilities that affect the majority of production APIs. Review it before every launch, and revisit it quarterly as your API surface grows.

Leave a Comment