AI Dev Tools
·3 min read·checklist

The Production-Grade Backend API Security Checklist

Secure your production services with this comprehensive backend api security checklist covering rate limiting, JWT validation, token rotation, and secure architecture.

Most API security failures aren't exotic zero-days. They're the same class of mistakes repeated across codebases: tokens validated incorrectly, rate limits applied only by IP (trivially bypassed by a botnet), user-supplied URLs fetched without checking where they resolve, and resource IDs that let you enumerate the entire database by incrementing a number.

This checklist covers the six controls that close the most common attack vectors in production APIs.


1. Verify JWTs Against a Cached JWKS Endpoint

Don't hardcode public keys and don't fetch the JWKS on every request. A cached JWKS client validates token signatures against the current keyset and handles key rotation automatically without hammering your auth server.

typescript
import { createRemoteJWKSet, jwtVerify } from 'jose';
 
const JWKS = createRemoteJWKSet(new URL('https://auth.company.com/.well-known/jwks.json'), {
  cooldownDuration: 30000, // cache for at least 30 seconds
  timeoutDuration: 5000,
});
 
export async function verifyToken(authHeader?: string) {
  if (!authHeader?.startsWith('Bearer ')) throw new Error('Missing Authorization header');
  const { payload } = await jwtVerify(authHeader.split(' ')[1], JWKS, {
    issuer: 'https://auth.company.com',
    audience: 'https://api.company.com',
  });
  return payload;
}

Always validate issuer and audience. A token signed by your auth server but intended for a different service should be rejected.


2. Block Token Replay with JTI Tracking

A valid JWT intercepted in transit can be replayed indefinitely until it expires. Include a jti (JWT ID) claim in your tokens and track used JTIs in Redis. A Bloom filter reduces memory overhead compared to a plain key-value set while keeping lookups sub-millisecond.

typescript
const hash = crypto.createHash('sha256').update(jti).digest('hex');
const seen = await redis.send_command('BF.EXISTS', 'tokens:bloom', hash);
if (seen === 1) throw new Error('Token already used');
await redis.send_command('BF.ADD', 'tokens:bloom', hash);

Set the Bloom filter expiry to match your token TTL, or use a rolling window keyed by hour to bound memory usage.


3. Use Sliding-Window Rate Limiting Per User, Not Just Per IP

IP-based rate limiting fails against distributed botnets and penalizes legitimate users behind corporate NATs. Combine IP with authenticated user ID or API key, and use a sliding-window counter in Redis rather than a fixed window (which resets all counts simultaneously and can be gamed):

typescript
const now = Date.now();
const clearBefore = now - windowSeconds * 1000;
const tx = redis.multi()
  .zremrangebyscore(key, 0, clearBefore)
  .zadd(key, now, now.toString())
  .zcard(key)
  .expire(key, windowSeconds);
const results = await tx.exec();
const count = results![2][1] as number;
return count > limit;

Apply tighter limits on sensitive endpoints (password reset, token exchange) than on general data reads.


4. Validate and Strip Payloads With Zod

Never trust payload structure. TypeScript types are erased at runtime — they don't protect you from a client sending extra fields designed to trigger prototype pollution or mass-assignment vulnerabilities. Parse input with Zod using .strict() to reject any undeclared properties outright:

typescript
const CreateUserSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/),
  profile: z.object({
    displayName: z.string().max(100),
    bio: z.string().max(500).optional(),
  }),
}).strict();

If safeParse fails, return 400 immediately. Never pass unvalidated data deeper into your controller.


5. Enforce Ownership in Database Queries

Using auto-incrementing integer IDs makes resource enumeration trivial — an attacker increments the ID to walk your entire dataset. Use UUIDs or ULIDs for external resource identifiers, and enforce ownership directly in every query rather than checking it in application code after fetching:

typescript
const document = await prisma.document.findFirst({
  where: { id: documentId, ownerId: ctx.userId, tenantId: ctx.tenantId },
});
if (!document) throw new Error('Resource not found'); // generic 404, not 403

Returning a generic 404 (instead of 403 Forbidden) prevents attackers from confirming that a resource exists but belongs to another user.


6. Guard Outbound Requests Against SSRF

If your API fetches a URL supplied by the user, you must validate where that URL actually resolves before making the request. Resolve the hostname to an IP first, then check that IP against private CIDR ranges. Without this, an attacker can point your server at 169.254.169.254 (the AWS metadata endpoint) or internal services on your VPC.

The check: resolve the hostname via DNS, reject if the resulting IP falls in 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, or 169.254.0.0/16. Then pin the resolved IP in your request rather than re-resolving at fetch time (DNS rebinding attacks change the resolution between the check and the fetch).


These six controls — JWKS validation, JTI tracking, sliding-window rate limiting, schema validation, ownership-enforced queries, and SSRF guards — address the majority of real API security incidents. None of them require an external service or a large library. They're patterns you can implement incrementally, starting with whichever gap is most exposed in your current stack.

ShareTweet

Related Posts