Vibe Code Bible
Security

API Security

API authentication, rate limiting, input validation, OWASP API Security Top 10, GraphQL security, webhook verification, and API versioning security. Every API endpoint is a contract — enforce it.

API Security

API authentication, rate limiting, input validation, OWASP API Security Top 10, GraphQL security, webhook verification, and API versioning security. Every API endpoint is a contract — enforce it.


Principles

1. API Security Threat Landscape

APIs are the primary attack surface of modern applications. Over 80% of web traffic is now API traffic, and the proportion keeps climbing as single-page applications, mobile apps, and microservice architectures replace traditional server-rendered pages. OWASP recognized this shift when they released a dedicated API Security Top 10 (2023), completely separate from the traditional web application Top 10, because API vulnerabilities are fundamentally distinct from browser-based web vulnerabilities.

API-first architectures expand the attack surface dramatically. Every endpoint is potentially public. Even "internal" endpoints can be discovered through mobile app reverse engineering, JavaScript bundle analysis, or network traffic inspection. The boundary between "public" and "private" APIs is thinner than most teams realize — if it is reachable over HTTP, it is an attack surface.

The rise of mobile apps, SPAs, and microservices means more APIs, more endpoints, and more attack vectors. A single product might expose a REST API for its web frontend, a GraphQL API for its mobile app, internal gRPC services for microservice communication, and webhook endpoints for third-party integrations. Each of these is a separate attack surface with its own security requirements.

Common API attack patterns include:

  • Enumeration: scraping data by iterating sequential IDs (/api/users/1, /api/users/2, ..., /api/users/100000). Attackers harvest user data, order details, or any resource with predictable identifiers.
  • Credential stuffing: automated login attempts using breached username/password pairs from other services. Without rate limiting, attackers can test millions of credentials against your login endpoint.
  • Data exposure: APIs that return full database objects instead of curated response shapes, leaking internal fields, sensitive attributes, or related data the client never needed.
  • Business logic abuse: using the API in unintended ways — applying discount codes infinitely, reserving all inventory, creating thousands of accounts, or automating workflows meant for human users.
  • Parameter tampering: modifying request parameters to escalate privileges, change prices, or access unauthorized resources.
  • Injection through API parameters: SQL injection, NoSQL injection, and command injection through poorly validated API input fields.

The fundamental mindset shift: traditional web security assumed the server controlled the UI and the user interacted through forms. API security assumes the client is hostile. Every request is potentially crafted by an attacker, not generated by your frontend. Design accordingly.

2. API Authentication Strategies

Authentication answers one question: who is making this request? The answer determines everything else — authorization, rate limits, audit logging, and data access. Choose the right authentication mechanism for each use case.

API Keys are the simplest mechanism: a unique string per client. The server generates a random, high-entropy string and associates it with a client account. The client includes the key in every request. API keys are best for server-to-server communication, rate limit tracking, and identifying which client is making requests. They are NOT suitable for user authentication because they carry no user context — an API key identifies an application, not a person. Always send API keys in a header (X-API-Key or Authorization: ApiKey <key>). NEVER send them in URL query parameters. URLs are logged in server access logs, browser history, proxy logs, CDN logs, and referrer headers. A key in a URL is a key in a dozen log files.

OAuth 2.0 Bearer Tokens are the standard for user-authorized access. The flow issues an access token after the user authenticates and authorizes the client. The access token is sent in the Authorization: Bearer <token> header on every request. Access tokens are short-lived (15 minutes to 1 hour) and paired with a long-lived refresh token for renewal. Best for user-facing APIs, third-party integrations, and any scenario where a user delegates access to a client application. OAuth 2.0 supports multiple grant types: Authorization Code (with PKCE for public clients), Client Credentials (for machine-to-machine), and Refresh Token. Never use the Implicit grant — it is deprecated and insecure.

JWT (JSON Web Tokens) can serve as the bearer token format. JWTs are self-contained — they carry claims (user ID, roles, expiration) and a cryptographic signature. The server can verify a JWT without a database lookup, which is valuable for stateless architectures and microservices. Use RS256 or ES256 (asymmetric) over HS256 (symmetric) so that services can verify tokens without possessing the signing key. Always validate: the signature, the exp (expiration) claim, the iss (issuer) claim, and the aud (audience) claim. Never trust JWT claims without signature verification. Never store sensitive data in JWT payloads — they are base64-encoded, not encrypted.

mTLS (Mutual TLS) requires both client and server to present TLS certificates, verified by a shared certificate authority. This is the strongest authentication mechanism for service-to-service communication in zero-trust environments. Both parties cryptographically prove their identity. Most secure but most complex — requires certificate management, rotation, and a PKI infrastructure. Works exceptionally well with service meshes like Istio or Linkerd.

When to use which:

Use CaseRecommended Mechanism
Public API with rate limitsAPI Keys
User-facing API (web/mobile)OAuth 2.0 with JWT bearer tokens
Internal microservicesmTLS or JWT with service identities
Third-party integrationsOAuth 2.0 Authorization Code with PKCE
Webhooks from external servicesHMAC signature verification
Machine-to-machine (no user)OAuth 2.0 Client Credentials or mTLS

Layer these mechanisms. A public API might use API keys for client identification AND OAuth 2.0 bearer tokens for user authentication. An internal API might use mTLS for transport-level authentication AND JWT for application-level identity propagation.

3. Rate Limiting Strategies

Rate limiting is the first line of defense against abuse, enumeration, credential stuffing, and denial of service. Without rate limiting, a single attacker can exhaust your server resources, scrape your entire database, or brute-force every account.

Token bucket gives each client a "bucket" of tokens. Each request consumes one token. Tokens refill at a fixed rate (e.g., 10 per second). The bucket has a maximum capacity (e.g., 100 tokens), allowing bursts up to the bucket size. This is the most widely used algorithm because it handles bursty traffic gracefully while enforcing an average rate.

Sliding window counts requests in a rolling time window (e.g., the last 60 seconds). Every second, the window shifts forward. Smoother than fixed window because there is no boundary effect. Implementation is slightly more complex — typically uses a sorted set in Redis with timestamps.

Fixed window counts requests per calendar interval (e.g., per minute, per hour). Simpler to implement but allows burst at window boundaries — a client can make 100 requests at 11:59:59 and 100 more at 12:00:01, effectively getting 200 requests in 2 seconds. Acceptable for coarse-grained limits but not ideal for abuse prevention.

Leaky bucket processes requests at a fixed rate from a queue. Incoming requests are added to the queue; if the queue is full, the request is rejected. Produces the smoothest output rate but adds latency because requests wait in the queue. Best for APIs where consistent throughput matters more than response time.

Response headers communicate limits to well-behaved clients:

HeaderMeaning
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUTC epoch timestamp when the limit resets
Retry-AfterSeconds until the client can retry (on 429)

When a client exceeds the limit, return 429 Too Many Requests with the Retry-After header specifying the number of seconds until the client can retry. Include a JSON body with an error message explaining the limit.

Rate limit by multiple dimensions simultaneously. Rate limit by API key to cap total usage per client. Rate limit by user ID to prevent a single user from monopolizing a shared API key. Rate limit by IP address to catch unauthenticated abuse. Apply different limits for different operations — reads are cheaper than writes, search is cheaper than create, login attempts need the strictest limits. Provide more generous limits for authenticated users than anonymous users, incentivizing proper authentication.

Critical endpoints need aggressive limits. Login endpoints: 5-10 attempts per minute per IP. Password reset: 3 attempts per hour per email. Account creation: 5 per hour per IP. Payment endpoints: match your actual expected traffic patterns.

4. Input Validation and Output Encoding

Every API request MUST be validated against a schema before any business logic executes. Input validation is the boundary between the hostile outside world and your trusted application internals. Fail early, fail loudly, and never process unvalidated data.

OpenAPI/Swagger schema enforcement defines your API contract formally. Every request — method, path, headers, query parameters, and body — should match the OpenAPI spec. Use middleware that validates incoming requests against the spec and rejects non-conforming requests automatically. This is your first validation layer. Run in strict mode: reject requests with unexpected fields rather than silently ignoring them. Unknown fields are either a bug in the client or an attack.

Content-Type enforcement is a simple but critical check. If your API accepts JSON, only accept application/json. Reject text/xml, application/x-www-form-urlencoded, multipart/form-data, and anything else you did not explicitly design for. Content-Type confusion attacks exploit APIs that accept multiple formats inconsistently. Set the Content-Type: application/json response header explicitly on every JSON response.

Request body validation using Zod, Joi, or AJV should validate every field with full specificity: type (string, number, boolean), format (email, URL, UUID, ISO date), length (min/max for strings), range (min/max for numbers), enum values (only allow specific strings), and patterns (regex for structured strings like phone numbers). Do not just check that a field is a string — check that it is a string of the expected format and length. A "name" field that accepts 10 million characters is a resource exhaustion vector.

Query parameter validation is frequently overlooked. Parse and validate pagination parameters: page must be a positive integer, limit must be a positive integer with a hard maximum (e.g., 100). Sort fields must come from an allowlist of sortable columns — never pass a client-provided string directly to an ORDER BY clause. Filter values must be validated against expected types and ranges. A missing validation on ?limit=999999999 turns a paginated endpoint into a full database dump.

Prototype pollution is a JavaScript-specific vulnerability where an attacker sets __proto__, constructor, or prototype fields in a JSON body to modify the behavior of all objects in the application. If your JSON parser merges request data into an existing object, an attacker can inject properties into Object.prototype, affecting every object in your application. Prevention: use Object.create(null) for hash maps (objects without a prototype chain), freeze prototypes with Object.freeze(Object.prototype), use a JSON schema validator that explicitly rejects keys like __proto__ and constructor.prototype, or use libraries like secure-json-parse.

Output encoding ensures API responses do not include raw HTML that could be rendered if the response is ever displayed in a browser context. Always set Content-Type: application/json explicitly. Sanitize string fields that could contain user-generated content. Never reflect raw input back to the client without encoding. Consider setting X-Content-Type-Options: nosniff to prevent browsers from interpreting JSON responses as HTML.

5. OWASP API Security Top 10 (2023) Deep Dive

The OWASP API Security Top 10 (2023) is the definitive reference for API vulnerabilities. Every developer building APIs must understand these categories. They represent the most common and impactful API security flaws found in real-world assessments.

API1: Broken Object Level Authorization (BOLA) is the number one API vulnerability. It occurs when the API does not verify that the authenticated user has permission to access the specific object they requested. The attack vector is trivial: change an ID in the URL and access another user's data. Prevention: always include ownership or permission checks in every data access query.

API2: Broken Authentication covers weak authentication mechanisms. Common vectors: no rate limiting on login endpoints allowing brute force, credentials sent over unencrypted channels, weak password policies, token leakage through logs or URLs, failure to validate token signatures. Prevention: implement strong authentication with rate limiting, use established libraries, validate all token claims.

API3: Broken Object Property Level Authorization combines two related issues — excessive data exposure (returning properties the client should not see) and mass assignment (accepting properties the client should not set). The API returns a full user object including isAdmin, internalNotes, and passwordHash. Or the API accepts a request body with isAdmin: true and applies it. Prevention: explicitly define response shapes (never return raw database objects) and explicitly define which fields are writable.

API4: Unrestricted Resource Consumption covers missing or inadequate rate limiting, no pagination limits on list endpoints, allowing expensive queries or operations, no limits on file upload sizes, or no limits on batch operation sizes. Prevention: implement rate limiting on all endpoints, enforce maximum page sizes, limit query complexity, set file size limits, and cap batch operation sizes.

API5: Broken Function Level Authorization occurs when regular users can access admin endpoints, or lower-privilege roles can access higher-privilege functions. The attack vector: discover admin endpoints through documentation, JavaScript bundles, or enumeration, then call them directly. Prevention: implement role-based access control at the middleware level, deny by default, and audit every endpoint for proper authorization.

API6: Unrestricted Access to Sensitive Business Flows is about automating flows that were designed for human interaction. Attackers bot-purchase limited inventory, mass-create accounts for spam, automate coupon redemption, or scrape competitive data. Prevention: implement CAPTCHA for human-facing flows, detect and block automation patterns, implement business-logic rate limits (not just technical rate limits).

API7: Server-Side Request Forgery (SSRF) makes the server request arbitrary URLs specified by the attacker. Common vectors: webhook URLs, URL preview features, file import from URL, PDF generation from user-provided content. Prevention: validate and allowlist destination URLs, block private IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x), use a dedicated proxy for outbound requests.

API8: Security Misconfiguration is the broadest category — default configurations, verbose error messages revealing stack traces, missing security headers, unnecessary HTTP methods enabled, CORS misconfigured to allow any origin, TLS not enforced. Prevention: harden default configurations, suppress detailed error messages in production, configure CORS explicitly, enforce HTTPS, disable unused HTTP methods.

API9: Improper Inventory Management means you have API endpoints you have forgotten about. Old API versions still running with known vulnerabilities. Debug endpoints left in production. Internal endpoints exposed to the internet. Deprecated endpoints with no sunset date. Prevention: maintain a comprehensive API inventory, automate API discovery, enforce sunset policies for deprecated versions, remove debug endpoints from production builds.

API10: Unsafe Consumption of APIs is the reverse of the usual concern — your application is the client consuming third-party APIs, and it trusts their responses without validation. A compromised or buggy third-party API sends malicious data that your application processes blindly. Prevention: validate all data from third-party APIs with the same rigor you validate user input. Apply timeouts, circuit breakers, and error handling to all external API calls.

6. Broken Object-Level Authorization (BOLA/IDOR)

This vulnerability gets its own section because it is, year after year, the number one API security flaw. It is simultaneously the easiest to exploit and the easiest to prevent, yet it persists across the industry because developers forget to add authorization checks consistently.

BOLA (Broken Object Level Authorization), also called IDOR (Insecure Direct Object Reference), occurs when the API does not verify that the authenticated user has permission to access the specific resource they requested. The user is authenticated — the API knows who they are — but it does not check whether they own or have access to the specific object.

The attack pattern is trivial. An attacker sends GET /api/orders/123 and receives their own order. They change the URL to GET /api/orders/124 and receive another user's order. The API checked "is this user logged in?" but not "does this user own order 124?" This applies to every CRUD operation: reading, updating, and deleting resources.

Prevention is straightforward: ALWAYS include an ownership or permissions check at the data layer. In Prisma, use prisma.order.findFirst({ where: { id: orderId, userId: currentUser.id } }) — the userId condition ensures ownership. The query returns null if the resource does not exist OR if the user does not own it, and the API returns 404 in both cases (do not distinguish between "not found" and "not authorized" — that leaks information about the existence of resources).

UUID vs. sequential IDs is a defense-in-depth consideration. Using UUIDs (550e8400-e29b-41d4-a716-446655440000) instead of sequential integers (124) makes enumeration harder because attackers cannot simply increment an ID. However, UUIDs are NOT a fix for BOLA. UUIDs can be leaked in URLs, logs, emails, WebSocket messages, and API responses. An attacker who obtains a UUID through any channel can access the resource if there is no authorization check. UUIDs make enumeration harder; authorization checks make unauthorized access impossible. Use both.

This check must be applied on EVERY endpoint that accesses user-specific data — not just "important" endpoints. Developers often secure the "get order" endpoint but forget the "get order invoice PDF" endpoint, or the "get order tracking" endpoint. Every endpoint. No exceptions. Middleware that injects the current user's ID into the database query context is the most reliable approach because it makes the authorization check automatic rather than manual.

For resources shared between users (team projects, organization data, shared documents), the authorization check is more complex but equally mandatory. Check team membership, organization role, or explicit sharing permissions. Use a consistent authorization framework rather than ad-hoc checks scattered across handlers.

7. API Versioning Security

API versioning is a feature management concern that has significant security implications. Deprecated API versions are attack vectors because they often lack security patches, input validation improvements, or rate limiting enhancements that were added to newer versions.

A sunset policy is mandatory for every deprecated API version. When deprecating a version: announce the sunset date well in advance (minimum 6 months for public APIs), return Sunset and Deprecation HTTP headers on every response from the deprecated version, monitor usage to identify clients that have not migrated, and eventually return 410 Gone for all requests to the sunset version. Do not leave old versions running indefinitely — they accumulate vulnerabilities.

Version numbering should not reveal internal details. Avoid /api/v2.3.1/ because it exposes your internal release cadence. Use major versions only: /api/v1/, /api/v2/. Semantic versioning is for packages, not API URLs.

Security patches must be applied to ALL supported versions, not just the latest. If you discover a BOLA vulnerability in your order endpoint, you must fix it in v1, v2, and v3 — not just v3. If maintaining security across multiple versions is too burdensome, that is a strong signal to sunset older versions faster.

API inventory management is critical. Maintain a comprehensive list of all API endpoints — including internal, deprecated, debug, test, and admin endpoints. You cannot secure what you do not know exists. Automated API discovery tools can help identify endpoints that are not in your documentation. Remove debug endpoints (/api/debug, /api/test, /api/health/full, /api/_internal) before production deployment. Use build-time stripping or environment-based route registration to ensure debug routes never reach production.

Ensure API version routing does not allow version bypass. If an attacker requests /api/v0/orders or /api/v99/orders, the server should return 404, not fall through to a default version. Explicit version matching prevents version confusion attacks.

8. GraphQL-Specific Security

GraphQL's flexibility — the ability for clients to query exactly the data they need in any shape they want — creates unique security challenges that do not exist in REST APIs. The same features that make GraphQL powerful for developers make it powerful for attackers.

Query depth limiting prevents deeply nested queries that cause cascading database lookups. A query like { user { posts { comments { author { posts { comments { author { ... } } } } } } } } can trigger millions of database queries through N+1 relationships. Set a maximum query depth (typically 7-10 levels) and reject queries that exceed it. This is a hard limit enforced at query parsing time, before any resolvers execute.

Query complexity analysis assigns a cost to each field in the schema. A scalar field like name costs 1. A list field like posts costs 10 (or 10 times the requested limit). A connection with pagination costs proportionally to the requested page size. Each query has a total complexity budget (e.g., 1000 points), and queries exceeding the budget are rejected. This prevents attackers from crafting queries that are within the depth limit but still astronomically expensive.

Introspection must be disabled in production. The __schema and __type queries expose your entire API schema — every type, every field, every argument, every enum value. This is invaluable for development and should be enabled in development/staging environments. In production, disable it. Attackers use introspection as the first step in reconnaissance. If you need to share your schema with partners, provide a static schema file — do not leave introspection open.

Field-level authorization is essential because GraphQL schemas often expose types that are shared across contexts with different authorization requirements. Just because a user can query User in one context does not mean they should see User.email, User.phone, or User.ssn. Apply authorization at the field resolver level. Use schema directives or middleware to declare authorization requirements on sensitive fields. A field that requires admin access should check the user's role before resolving.

Batching attacks exploit GraphQL's ability to execute multiple operations in a single HTTP request. An attacker sends a single POST with an array of 1000 login mutation attempts — bypassing HTTP-level rate limiting that counts requests. Rate limit by operation count, not just HTTP request count. If a single request contains 100 queries, that counts as 100 operations against the rate limit.

Persisted queries are the most secure approach for production GraphQL APIs. Pre-register all allowed queries during the build process, assigning each a hash. In production, clients send the hash instead of the query text. The server looks up the hash and executes the pre-registered query. Ad-hoc queries are rejected entirely. This eliminates query injection, depth attacks, complexity attacks, and introspection in one mechanism. The tradeoff is reduced flexibility — clients can only execute queries that were registered at build time.

9. Webhook Security

Webhooks are HTTP callbacks from external services to your application — Stripe notifying you of a payment, GitHub notifying you of a push, Twilio notifying you of an SMS. They are inbound API calls that your application receives and processes.

The fundamental security concern: anyone on the internet can POST to your webhook endpoint. An attacker can craft a fake Stripe payment notification, a fake GitHub push event, or a fake Twilio message. If your application processes these without verification, the attacker controls your application's behavior.

Signature verification is the primary defense. Most reputable webhook providers (Stripe, GitHub, Twilio, Shopify, Slack) sign the payload with HMAC-SHA256 using a shared secret. The provider computes the HMAC of the request body using a secret that only you and the provider know, and includes the signature in a request header. Your endpoint MUST verify the signature before processing the webhook.

The verification process: concatenate the timestamp and the raw request body (the exact bytes, not parsed-and-re-serialized JSON), compute HMAC-SHA256 with the webhook secret, and compare the result to the signature header. Use timing-safe comparison (crypto.timingSafeEqual in Node.js) to prevent timing attacks that could leak the secret. Never use === for signature comparison — string comparison short-circuits on the first different character, and the timing difference reveals information about the correct signature.

Timestamp validation prevents replay attacks. Webhook payloads include a timestamp indicating when they were generated. Reject webhooks with timestamps older than 5 minutes (or whatever tolerance your provider recommends). Without timestamp validation, an attacker who intercepts a valid webhook can replay it indefinitely.

Idempotency is required because webhooks can be sent multiple times. The provider might retry due to network issues, timeouts, or your server returning a non-200 status code. Use the event ID (included in the payload) to deduplicate. Before processing, check if you have already processed an event with this ID. Store processed event IDs in a database or cache with a TTL matching your provider's retry window.

Asynchronous processing is the recommended pattern. Accept the webhook immediately (return 200 within a few seconds), then process it in a background job queue. This prevents timeouts — if your processing takes more than 10-30 seconds, the provider will assume failure and retry, creating duplicate processing. Immediate acknowledgment with background processing eliminates this problem.

IP allowlisting provides an additional layer of defense. Some providers publish the IP ranges of their webhook servers (Stripe, GitHub, and others document these). If your infrastructure supports it, restrict access to webhook endpoints to these IP ranges. This prevents attackers from even reaching your endpoint. However, IP ranges can change, so monitor provider announcements and update your allowlists accordingly.

Raw body handling is a critical implementation detail. Signature verification requires the raw request body — the exact bytes that were sent. If your framework parses the body as JSON before your signature verification code runs, and you then re-serialize the parsed JSON, the bytes may differ (field ordering, whitespace, Unicode escaping). Use raw body middleware (like express.raw({ type: 'application/json' })) for webhook endpoints.

10. API Documentation and Exposure

API documentation is a double-edged sword. It is necessary for developers to use your API effectively and essential for integration partners. But it is equally valuable for attackers performing reconnaissance. The information you provide to help legitimate users also helps attackers understand your data model, authentication mechanisms, and endpoint structure.

Never expose internal endpoints in public documentation. Internal endpoints for admin panels, debugging, metrics, and health checks should not appear in your public API docs. Maintain separate documentation for internal and external APIs. Use access controls on internal documentation — do not assume that an internal wiki URL is "secure enough."

Swagger/OpenAPI UI should be authentication-gated in production. The interactive Swagger UI that lets developers try API calls in the browser is a powerful tool — for developers and attackers. In production, either disable it entirely, place it behind authentication, or restrict it to internal networks. A publicly accessible Swagger UI is a complete map of your API for attackers.

Remove or disable debug endpoints before deployment. Endpoints like /api/debug, /api/test, /api/_internal, /api/health/full (with detailed system information), and /api/config should never reach production. Use environment-based route registration so debug routes are only registered in development. Build-time stripping is even more reliable.

Admin API endpoints should be on a separate host or behind a VPN — not on the same origin as the public API. If your public API is at api.example.com, your admin API should be at admin-api.internal.example.com, accessible only from your corporate network or VPN. Placing admin endpoints at api.example.com/admin/... means the only thing protecting them is your authorization middleware — one misconfiguration and they are public.

Error messages should not reveal internal schema details, field names, database details, or stack traces. A production error response should contain a generic error message, an error code for programmatic handling, and a request ID for support correlation. It should never contain SQL queries, database table names, internal file paths, stack traces, or library version numbers.

API discovery is a real threat. Attackers use tools like ffuf, dirbuster, and gobuster to brute-force endpoint paths. They try common patterns: /api/admin, /api/internal, /api/debug, /api/v1/users, /api/graphql. The best defense is ensuring undocumented endpoints genuinely do not exist in production — not trying to hide them through obscurity.

11. Service-to-Service Authentication

Internal APIs need authentication too. The "network perimeter" model — trusting everything inside the firewall — is dead. Cloud environments, container orchestration, and microservice architectures mean that the network is not a trust boundary. A compromised service should not be able to impersonate any other service.

mTLS (Mutual TLS) is the gold standard for service-to-service authentication. Both the client and server present TLS certificates during the handshake, verified by a shared certificate authority (CA). The server verifies the client's identity, and the client verifies the server's identity. This provides strong, cryptographic, bidirectional authentication at the transport layer. mTLS works exceptionally well with service meshes because the mesh sidecar handles certificate management, rotation, and verification transparently. The application code does not need to handle any authentication logic.

JWT with service identities assigns each service a unique identity and a private key. When service A calls service B, service A generates a JWT signed with its private key, containing claims like iss (issuer: "order-service"), aud (audience: "payment-service"), and exp (expiration). Service B verifies the signature using service A's public key and checks the claims. This approach works well when you need application-level identity that travels through proxies and load balancers. Use short-lived tokens (1-5 minutes) and rotate signing keys regularly.

API gateway pattern places a central gateway in front of all services. The gateway handles authentication, rate limiting, request routing, and logging. Individual services trust the gateway and accept requests that have been authenticated and authorized by it. This centralizes security logic but creates a single point of failure and a high-value target. Ensure the gateway is hardened, redundant, and its communication with backend services is also authenticated (mTLS or signed headers).

Service mesh (Istio, Linkerd) provides mTLS, authorization policies, and observability for service-to-service communication automatically. The mesh injects a sidecar proxy alongside each service that handles all network communication. The sidecar enforces mTLS, applies authorization policies (which service can call which service), and collects telemetry. This is the most comprehensive approach for Kubernetes-based microservice architectures.

Zero-trust networking is the overarching principle: do not trust the network. Every request is authenticated and authorized, even if it comes from a "trusted" internal network segment. No service gets implicit trust based on its network location. This is not a specific technology but an architectural principle that should guide every service-to-service communication decision.


LLM Instructions

1. Designing Secure API Endpoints

Every API endpoint you create must implement five security layers, and you must not ship an endpoint that is missing any of them. First, authentication: every endpoint must know who is calling it. Attach authentication middleware to every route, even read-only public endpoints should identify the caller when possible. Second, authorization: every endpoint must verify that the authenticated caller is allowed to perform this specific action on this specific resource. Do not assume that authentication implies authorization. Third, input validation: every field in the request — body, query parameters, URL parameters, headers — must be validated against a strict schema before any business logic executes. Fourth, rate limiting: every endpoint must be rate-limited, with limits appropriate to the operation. Read endpoints can be more generous than write endpoints. Authentication endpoints need the strictest limits. Fifth, output filtering: every response must contain only the fields the caller is authorized to see, never raw database objects.

When creating a new endpoint, implement all five layers before writing the business logic. Use middleware for cross-cutting concerns like authentication and rate limiting. Use per-endpoint logic for input validation schemas and authorization checks. Structure your route definitions so that the middleware chain is visible and auditable. A route definition should read like a security specification: authenticate, authorize, validate, rate-limit, then handle.

When generating Express route handlers, always attach the authentication middleware first, followed by the authorization middleware that checks roles or permissions, followed by the validation middleware that validates the request schema, and finally the handler function. When generating Next.js API routes, implement the same layers at the top of the handler function, returning early with appropriate HTTP status codes for each failed check.

2. Implementing BOLA/IDOR Prevention

For every endpoint that accesses user-specific data, you must include the user ID in the database query — not just the resource ID. This is the single most important security pattern for API development.

In Prisma, always add userId: currentUser.id to the where clause. For a single resource lookup: use findFirst with both the resource ID and the user ID, and return 404 if no result is found. For list queries: use findMany with the user ID to scope results to the current user's data. For updates: use updateMany with both the resource ID and user ID so that the update only succeeds if the user owns the resource. For deletes: use deleteMany with both conditions.

In raw SQL, always include AND user_id = $currentUserId in the WHERE clause. Never fetch a resource by ID alone and then check ownership in application code — that pattern is vulnerable to race conditions and is easily forgotten.

Never rely on the client to only request their own resources. The client is untrusted. Even if your frontend only displays the current user's data, the API must enforce access control independently. An attacker bypasses your frontend entirely.

For shared resources like team projects, organization data, or shared documents, implement explicit permission checks. Verify team membership, organization role, or sharing permissions before returning data. Use a consistent authorization framework or helper function so that these checks are standardized across all endpoints. When generating code for multi-tenant applications, always include the tenant ID in the database query to prevent cross-tenant data access.

3. Securing GraphQL APIs

When creating or modifying a GraphQL API, you must install and configure four security mechanisms: query depth limiting, query complexity analysis, introspection control, and field-level authorization.

For query depth limiting, set a maximum depth of 7-10 levels depending on your schema's natural nesting. Install a depth-limiting plugin that rejects queries exceeding the limit before execution. For query complexity analysis, assign costs to fields based on their expense. Scalar fields cost 1 point. List fields cost their multiplier times the requested limit. Set a total complexity budget of 1000-2000 points and reject queries exceeding it.

Disable introspection in production environments. Use an environment variable to toggle introspection so it is available in development and staging but disabled in production. If your API serves a known set of clients, use persisted queries to limit execution to pre-registered queries only.

Apply field-level authorization in resolvers for any field containing sensitive data. When a user type includes fields like email, phone, address, or any PII, wrap those resolvers in authorization checks that verify the requesting user has permission to see those specific fields. Use schema directives to declare authorization requirements declaratively.

Rate limit GraphQL APIs by operation count, not just HTTP request count. When generating rate limiting configuration for GraphQL, count each operation in a batched request separately against the rate limit. Reject batched requests containing more than 10-20 operations.

When validating input arguments in GraphQL resolvers, apply the same rigor as REST API validation. Define Zod schemas for every input type and validate arguments at the start of each resolver. Do not trust GraphQL's type system alone — it validates types but not business rules like maximum lengths, allowed enum values, or format constraints.

4. Implementing Webhook Receivers

When generating code that receives webhooks from external services, always implement signature verification as the first step in the handler. Never process a webhook payload without verifying its authenticity.

Use the raw request body for signature computation, not the parsed JSON. Configure your HTTP framework to provide the raw body for webhook endpoints. In Express, use express.raw() middleware for the webhook route. In Next.js, disable the body parser for the webhook API route and read the raw body manually.

Validate the timestamp included in the webhook payload. Reject payloads with timestamps older than 5 minutes to prevent replay attacks. Compute the time difference using the server's current time and the webhook timestamp.

Return a 200 status code immediately after signature verification succeeds, then process the webhook asynchronously in a background job. Do not perform long-running operations in the webhook handler — the provider will time out and retry, causing duplicate processing.

Implement idempotency using the event ID from the webhook payload. Before processing, check if the event ID has already been processed. Store processed event IDs in your database or a Redis cache with a TTL matching the provider's retry window, typically 24-72 hours.

Store webhook secrets in environment variables, never in source code. When the secret needs to be rotated, support a transition period where both the old and new secrets are valid, then remove the old secret once the provider has been updated.

5. Validating API Input with OpenAPI and Zod

When generating API endpoints, define a Zod schema for every request body, every set of query parameters, and every set of URL parameters. Validation must happen at the very start of the handler, before any business logic, database queries, or side effects.

Define schemas that are as specific as possible. Do not use z.string() when you mean z.string().email(). Do not use z.number() when you mean z.number().int().min(1).max(100). Do not use z.object() with optional fields when every field is actually required. Add .max() constraints to every string and array field to prevent resource exhaustion. A name field should be z.string().min(1).max(255), not z.string().

Return structured validation errors with field-level messages. When a request fails validation, the response should identify which fields failed and why, formatted consistently across all endpoints. Use HTTP status code 400 for validation errors, with a JSON body containing an array of field-level error objects with the field path and error message.

Use strict schemas that reject unknown fields. In Zod, use .strict() on object schemas so that requests with unexpected fields are rejected. Unknown fields in a request body are either a client bug or an injection attempt — either way, reject them.

For pagination, enforce maximum page size limits. Define the page size parameter as z.number().int().min(1).max(100).default(20). Never allow unlimited page sizes. A request with limit=1000000 should be rejected, not honored. For cursor-based pagination, validate that the cursor is a valid format and handle invalid cursors gracefully with a clear error message rather than a database error.


Examples

1. API Key Authentication with Rate Limiting (Express)

// middleware/apiKeyAuth.ts
import { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export interface AuthenticatedRequest extends Request {
  apiClient?: {
    id: string;
    name: string;
    tier: 'free' | 'pro' | 'enterprise';
  };
}

export async function apiKeyAuth(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const apiKey = req.headers['x-api-key'] as string | undefined;

  if (!apiKey) {
    res.status(401).json({
      error: 'MISSING_API_KEY',
      message: 'Provide your API key in the X-API-Key header.',
    });
    return;
  }

  // Hash the key before lookup — store only hashed keys in the database
  const crypto = await import('crypto');
  const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');

  const client = await prisma.apiClient.findUnique({
    where: { keyHash },
    select: { id: true, name: true, tier: true, isActive: true },
  });

  if (!client || !client.isActive) {
    res.status(401).json({
      error: 'INVALID_API_KEY',
      message: 'The provided API key is invalid or has been revoked.',
    });
    return;
  }

  req.apiClient = { id: client.id, name: client.name, tier: client.tier };
  next();
}
// middleware/rateLimiter.ts
import { Response, NextFunction } from 'express';
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
import { AuthenticatedRequest } from './apiKeyAuth';

const redisClient = new Redis(process.env.REDIS_URL!);

// Tiered rate limits: requests per minute
const TIER_LIMITS: Record<string, { points: number; duration: number }> = {
  anonymous: { points: 30, duration: 60 },
  free: { points: 100, duration: 60 },
  pro: { points: 500, duration: 60 },
  enterprise: { points: 2000, duration: 60 },
};

function createLimiter(tier: string): RateLimiterRedis {
  const config = TIER_LIMITS[tier] ?? TIER_LIMITS.anonymous;
  return new RateLimiterRedis({
    storeClient: redisClient,
    keyPrefix: `ratelimit:${tier}`,
    points: config.points,
    duration: config.duration,
  });
}

const limiters: Record<string, RateLimiterRedis> = {};
for (const tier of Object.keys(TIER_LIMITS)) {
  limiters[tier] = createLimiter(tier);
}

export async function rateLimiter(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const tier = req.apiClient?.tier ?? 'anonymous';
  const key = req.apiClient?.id ?? req.ip ?? 'unknown';
  const limiter = limiters[tier];
  const limit = TIER_LIMITS[tier].points;

  try {
    const result = await limiter.consume(key, 1);

    // Always include rate limit headers
    res.set({
      'X-RateLimit-Limit': String(limit),
      'X-RateLimit-Remaining': String(result.remainingPoints),
      'X-RateLimit-Reset': String(
        Math.ceil(Date.now() / 1000 + result.msBeforeNext / 1000)
      ),
    });

    next();
  } catch (rateLimiterRes: any) {
    const retryAfter = Math.ceil(rateLimiterRes.msBeforeNext / 1000);

    res.set({
      'X-RateLimit-Limit': String(limit),
      'X-RateLimit-Remaining': '0',
      'X-RateLimit-Reset': String(
        Math.ceil(Date.now() / 1000 + rateLimiterRes.msBeforeNext / 1000)
      ),
      'Retry-After': String(retryAfter),
    });

    res.status(429).json({
      error: 'RATE_LIMIT_EXCEEDED',
      message: `Rate limit exceeded. Retry after ${retryAfter} seconds.`,
      retryAfter,
    });
  }
}
// routes/api.ts
import express from 'express';
import { apiKeyAuth } from '../middleware/apiKeyAuth';
import { rateLimiter } from '../middleware/rateLimiter';

const router = express.Router();

// Apply API key auth and rate limiting to all routes
router.use(apiKeyAuth);
router.use(rateLimiter);

router.get('/products', async (req, res) => {
  // Handler executes only after authentication and rate limiting pass
  const products = await prisma.product.findMany({
    take: 20,
    select: { id: true, name: true, price: true, category: true },
  });
  res.json({ data: products });
});

export default router;

2. BOLA/IDOR Prevention Pattern (Prisma + Express)

// VULNERABLE: Fetches order by ID without ownership check
// An attacker can access ANY user's order by changing the ID
router.get('/orders/:id', authenticate, async (req, res) => {
  // BAD: No ownership check — any authenticated user can access any order
  const order = await prisma.order.findUnique({
    where: { id: req.params.id },
  });

  if (!order) {
    return res.status(404).json({ error: 'NOT_FOUND' });
  }

  res.json({ data: order });
});
// SECURE: Always include userId in the query
router.get('/orders/:id', authenticate, async (req, res) => {
  // GOOD: Ownership enforced at the database query level
  const order = await prisma.order.findFirst({
    where: {
      id: req.params.id,
      userId: req.user.id, // Only returns the order if this user owns it
    },
    select: {
      id: true,
      status: true,
      total: true,
      items: true,
      createdAt: true,
      // Note: explicitly selected fields — no raw database object
    },
  });

  // Return 404 for both "not found" and "not yours" — don't leak existence
  if (!order) {
    return res.status(404).json({
      error: 'NOT_FOUND',
      message: 'Order not found.',
    });
  }

  res.json({ data: order });
});
// SECURE: List endpoint scoped to current user
router.get('/orders', authenticate, async (req, res) => {
  const page = Math.max(1, parseInt(req.query.page as string) || 1);
  const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));

  const orders = await prisma.order.findMany({
    where: { userId: req.user.id }, // Scoped to current user
    select: {
      id: true,
      status: true,
      total: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
    skip: (page - 1) * limit,
    take: limit,
  });

  const total = await prisma.order.count({
    where: { userId: req.user.id },
  });

  res.json({
    data: orders,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
});
// SECURE: Update with ownership check
router.patch('/orders/:id', authenticate, async (req, res) => {
  // updateMany with ownership check — returns count of updated records
  const result = await prisma.order.updateMany({
    where: {
      id: req.params.id,
      userId: req.user.id, // Only updates if this user owns it
      status: 'pending',    // Only update pending orders
    },
    data: {
      shippingAddress: req.body.shippingAddress,
    },
  });

  if (result.count === 0) {
    return res.status(404).json({
      error: 'NOT_FOUND',
      message: 'Order not found or cannot be modified.',
    });
  }

  res.json({ message: 'Order updated.' });
});
// Helper middleware: extract and validate the authenticated user
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

export interface AuthenticatedRequest extends Request {
  user: { id: string; email: string; role: string };
}

export async function authenticate(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({
      error: 'UNAUTHORIZED',
      message: 'Missing or invalid Authorization header.',
    });
    return;
  }

  const token = authHeader.slice(7);

  try {
    const payload = jwt.verify(token, process.env.JWT_PUBLIC_KEY!, {
      algorithms: ['RS256'],
      issuer: 'api.example.com',
      audience: 'api.example.com',
    }) as { sub: string; email: string; role: string };

    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
    };

    next();
  } catch {
    res.status(401).json({
      error: 'UNAUTHORIZED',
      message: 'Invalid or expired token.',
    });
  }
}

3. GraphQL Security Configuration (Apollo Server 4)

// graphql/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema } from 'graphql';

// --- Depth Limiting ---
// Reject queries deeper than 7 levels
const depthLimitRule = depthLimit(7, {
  ignore: ['__schema', '__type'], // Allow introspection depth in dev
});

// --- Complexity Analysis ---
// Reject queries with a complexity cost exceeding 1000
const complexityLimitRule = createComplexityLimitRule(1000, {
  scalarCost: 1,
  objectCost: 2,
  listFactor: 10, // List fields cost 10x their child cost
  formatErrorMessage: (cost: number) =>
    `Query complexity ${cost} exceeds the maximum allowed complexity of 1000.`,
});

// --- Introspection Toggle ---
const isProduction = process.env.NODE_ENV === 'production';

// --- Field-Level Authorization Directive ---
function authDirectiveTransformer(
  schema: GraphQLSchema,
  directiveName: string
): GraphQLSchema {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, directiveName);

      if (authDirective && authDirective.length > 0) {
        const requiredRole = authDirective[0].requires;
        const originalResolve = fieldConfig.resolve ?? defaultFieldResolver;

        fieldConfig.resolve = async (source, args, context, info) => {
          if (!context.user) {
            throw new Error('Authentication required.');
          }

          if (requiredRole && context.user.role !== requiredRole) {
            throw new Error(
              `Insufficient permissions. Required role: ${requiredRole}.`
            );
          }

          return originalResolve(source, args, context, info);
        };
      }

      return fieldConfig;
    },
  });
}

// --- Schema Definition ---
const typeDefs = `#graphql
  directive @auth(requires: String!) on FIELD_DEFINITION

  type User {
    id: ID!
    name: String!
    email: String! @auth(requires: "self_or_admin")
    phone: String @auth(requires: "self_or_admin")
    role: String! @auth(requires: "admin")
    orders: [Order!]! @auth(requires: "self_or_admin")
  }

  type Order {
    id: ID!
    status: String!
    total: Float!
    createdAt: String!
  }

  type Query {
    me: User
    user(id: ID!): User @auth(requires: "admin")
    users(limit: Int = 20, offset: Int = 0): [User!]! @auth(requires: "admin")
  }
`;

// Build and transform schema with auth directive
let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirectiveTransformer(schema, 'auth');

// --- Rate Limiting Plugin ---
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
import type { ApolloServerPlugin } from '@apollo/server';

const redisClient = new Redis(process.env.REDIS_URL!);

const gqlRateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'gql_ratelimit',
  points: 100,     // 100 operations per minute
  duration: 60,
});

const rateLimitPlugin: ApolloServerPlugin = {
  async requestDidStart({ contextValue }) {
    const userId = contextValue.user?.id ?? contextValue.ip ?? 'anonymous';

    // Count each operation in a batched request
    return {
      async didResolveOperation({ operation }) {
        try {
          await gqlRateLimiter.consume(userId, 1);
        } catch {
          throw new Error(
            'Rate limit exceeded. Please slow down your requests.'
          );
        }
      },
    };
  },
};

// --- Apollo Server Setup ---
const server = new ApolloServer({
  schema,
  validationRules: [depthLimitRule, complexityLimitRule],
  introspection: !isProduction, // Disable introspection in production
  plugins: [rateLimitPlugin],
});

await server.start();

// Mount on Express
app.use(
  '/graphql',
  expressMiddleware(server, {
    context: async ({ req }) => ({
      user: req.user ?? null,
      ip: req.ip,
    }),
  })
);

4. Webhook Signature Verification (Stripe + GitHub Patterns)

// webhooks/stripe.ts
import express, { Request, Response } from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

// CRITICAL: Use express.raw() for webhook routes — signature verification
// needs the raw bytes, not parsed JSON.
const router = express.Router();

router.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req: Request, res: Response) => {
    const signature = req.headers['stripe-signature'] as string;

    if (!signature) {
      res.status(400).json({ error: 'Missing stripe-signature header.' });
      return;
    }

    let event: Stripe.Event;

    try {
      // Stripe SDK handles: signature verification, timestamp validation,
      // and payload parsing in one call
      event = stripe.webhooks.constructEvent(
        req.body,       // Raw body buffer — NOT parsed JSON
        signature,
        webhookSecret
      );
    } catch (err: any) {
      console.error('Stripe webhook verification failed:', err.message);
      res.status(400).json({ error: 'Invalid webhook signature.' });
      return;
    }

    // Idempotency: check if we already processed this event
    const existingEvent = await prisma.processedWebhookEvent.findUnique({
      where: { eventId: event.id },
    });

    if (existingEvent) {
      // Already processed — acknowledge without reprocessing
      res.status(200).json({ received: true, deduplicated: true });
      return;
    }

    // Record the event ID immediately to prevent duplicate processing
    await prisma.processedWebhookEvent.create({
      data: {
        eventId: event.id,
        type: event.type,
        receivedAt: new Date(),
      },
    });

    // Acknowledge immediately — process asynchronously
    res.status(200).json({ received: true });

    // Queue background processing (using your job queue)
    await jobQueue.add('process-stripe-webhook', {
      eventId: event.id,
      eventType: event.type,
      data: event.data.object,
    });
  }
);

export default router;
// webhooks/github.ts — Manual HMAC-SHA256 verification
import express, { Request, Response } from 'express';
import crypto from 'crypto';

const router = express.Router();
const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET!;
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000; // 5 minutes

function verifyGitHubSignature(
  payload: Buffer,
  signatureHeader: string
): boolean {
  // GitHub sends: sha256=<hex-digest>
  const expectedSignature = signatureHeader;

  if (!expectedSignature.startsWith('sha256=')) {
    return false;
  }

  const computedHmac = crypto
    .createHmac('sha256', GITHUB_WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  const computedSignature = `sha256=${computedHmac}`;

  // CRITICAL: Use timing-safe comparison to prevent timing attacks.
  // String === short-circuits on the first different character, leaking
  // information about the correct signature through response timing.
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expectedSignature, 'utf8'),
      Buffer.from(computedSignature, 'utf8')
    );
  } catch {
    // Buffers of different lengths throw — signatures don't match
    return false;
  }
}

router.post(
  '/webhooks/github',
  express.raw({ type: 'application/json' }),
  async (req: Request, res: Response) => {
    const signature = req.headers['x-hub-signature-256'] as string;
    const deliveryId = req.headers['x-github-delivery'] as string;
    const eventType = req.headers['x-github-event'] as string;

    if (!signature || !deliveryId || !eventType) {
      res.status(400).json({ error: 'Missing required GitHub headers.' });
      return;
    }

    // Verify the signature
    if (!verifyGitHubSignature(req.body, signature)) {
      console.error('GitHub webhook signature verification failed.');
      res.status(401).json({ error: 'Invalid webhook signature.' });
      return;
    }

    // Idempotency check using the delivery ID
    const existing = await prisma.processedWebhookEvent.findUnique({
      where: { eventId: deliveryId },
    });

    if (existing) {
      res.status(200).json({ received: true, deduplicated: true });
      return;
    }

    // Record and acknowledge
    await prisma.processedWebhookEvent.create({
      data: {
        eventId: deliveryId,
        type: `github.${eventType}`,
        receivedAt: new Date(),
      },
    });

    // Acknowledge immediately
    res.status(200).json({ received: true });

    // Parse the raw body for processing
    const payload = JSON.parse(req.body.toString('utf8'));

    // Queue for background processing
    await jobQueue.add('process-github-webhook', {
      deliveryId,
      eventType,
      payload,
    });
  }
);

export default router;

5. OpenAPI Schema Validation Middleware (Express + Zod)

// validation/schemas.ts
import { z } from 'zod';

// --- Request Body Schemas ---

export const createOrderSchema = z
  .object({
    items: z
      .array(
        z.object({
          productId: z.string().uuid('Product ID must be a valid UUID.'),
          quantity: z
            .number()
            .int('Quantity must be a whole number.')
            .min(1, 'Minimum quantity is 1.')
            .max(100, 'Maximum quantity per item is 100.'),
        })
      )
      .min(1, 'Order must contain at least one item.')
      .max(50, 'Order cannot contain more than 50 items.'),
    shippingAddress: z.object({
      street: z.string().min(1).max(255),
      city: z.string().min(1).max(100),
      state: z.string().min(1).max(100),
      postalCode: z.string().min(1).max(20),
      country: z.string().length(2, 'Country must be a 2-letter ISO code.'),
    }),
    couponCode: z
      .string()
      .max(50)
      .regex(/^[A-Z0-9_-]+$/, 'Invalid coupon code format.')
      .optional(),
  })
  .strict(); // Reject unknown fields

export const updateOrderSchema = z
  .object({
    shippingAddress: z
      .object({
        street: z.string().min(1).max(255),
        city: z.string().min(1).max(100),
        state: z.string().min(1).max(100),
        postalCode: z.string().min(1).max(20),
        country: z.string().length(2),
      })
      .optional(),
    notes: z.string().max(1000).optional(),
  })
  .strict();

// --- Query Parameter Schemas ---

export const paginationSchema = z.object({
  page: z.coerce
    .number()
    .int()
    .min(1, 'Page must be at least 1.')
    .max(1000, 'Page cannot exceed 1000.')
    .default(1),
  limit: z.coerce
    .number()
    .int()
    .min(1, 'Limit must be at least 1.')
    .max(100, 'Maximum page size is 100.')
    .default(20),
  sort: z
    .enum(['createdAt', 'updatedAt', 'total', 'status'], {
      errorMap: () => ({
        message: 'Sort must be one of: createdAt, updatedAt, total, status.',
      }),
    })
    .default('createdAt'),
  order: z.enum(['asc', 'desc']).default('desc'),
});

export const orderFilterSchema = paginationSchema.extend({
  status: z
    .enum(['pending', 'confirmed', 'shipped', 'delivered', 'cancelled'])
    .optional(),
  minTotal: z.coerce.number().min(0).optional(),
  maxTotal: z.coerce.number().min(0).optional(),
});

// --- URL Parameter Schemas ---

export const resourceIdSchema = z.object({
  id: z.string().uuid('Resource ID must be a valid UUID.'),
});
// validation/middleware.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';

interface ValidationSchemas {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

/**
 * Validation middleware factory.
 * Validates request body, query params, and/or URL params against Zod schemas.
 * Replaces req.body/query/params with the validated (and transformed) data.
 */
export function validate(schemas: ValidationSchemas) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const errors: Array<{ location: string; field: string; message: string }> =
      [];

    // Validate request body
    if (schemas.body) {
      const result = schemas.body.safeParse(req.body);
      if (result.success) {
        req.body = result.data;
      } else {
        errors.push(...formatZodErrors(result.error, 'body'));
      }
    }

    // Validate query parameters
    if (schemas.query) {
      const result = schemas.query.safeParse(req.query);
      if (result.success) {
        req.query = result.data;
      } else {
        errors.push(...formatZodErrors(result.error, 'query'));
      }
    }

    // Validate URL parameters
    if (schemas.params) {
      const result = schemas.params.safeParse(req.params);
      if (result.success) {
        req.params = result.data;
      } else {
        errors.push(...formatZodErrors(result.error, 'params'));
      }
    }

    if (errors.length > 0) {
      res.status(400).json({
        error: 'VALIDATION_ERROR',
        message: 'Request validation failed.',
        details: errors,
      });
      return;
    }

    next();
  };
}

function formatZodErrors(
  error: ZodError,
  location: string
): Array<{ location: string; field: string; message: string }> {
  return error.issues.map((issue) => ({
    location,
    field: issue.path.join('.'),
    message: issue.message,
  }));
}
// routes/orders.ts — Using the validation middleware
import express from 'express';
import { authenticate } from '../middleware/authenticate';
import { validate } from '../validation/middleware';
import {
  createOrderSchema,
  updateOrderSchema,
  orderFilterSchema,
  resourceIdSchema,
} from '../validation/schemas';

const router = express.Router();

// List orders — validate query params (pagination, filters)
router.get(
  '/orders',
  authenticate,
  validate({ query: orderFilterSchema }),
  async (req, res) => {
    const { page, limit, sort, order, status, minTotal, maxTotal } = req.query;

    const where: any = { userId: req.user.id };
    if (status) where.status = status;
    if (minTotal !== undefined || maxTotal !== undefined) {
      where.total = {};
      if (minTotal !== undefined) where.total.gte = minTotal;
      if (maxTotal !== undefined) where.total.lte = maxTotal;
    }

    const [orders, total] = await Promise.all([
      prisma.order.findMany({
        where,
        select: { id: true, status: true, total: true, createdAt: true },
        orderBy: { [sort as string]: order },
        skip: ((page as number) - 1) * (limit as number),
        take: limit as number,
      }),
      prisma.order.count({ where }),
    ]);

    res.json({
      data: orders,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / (limit as number)),
      },
    });
  }
);

// Get single order — validate URL params
router.get(
  '/orders/:id',
  authenticate,
  validate({ params: resourceIdSchema }),
  async (req, res) => {
    const order = await prisma.order.findFirst({
      where: { id: req.params.id, userId: req.user.id },
      select: {
        id: true,
        status: true,
        total: true,
        items: true,
        shippingAddress: true,
        createdAt: true,
      },
    });

    if (!order) {
      return res.status(404).json({
        error: 'NOT_FOUND',
        message: 'Order not found.',
      });
    }

    res.json({ data: order });
  }
);

// Create order — validate body and reject unknown fields
router.post(
  '/orders',
  authenticate,
  validate({ body: createOrderSchema }),
  async (req, res) => {
    const { items, shippingAddress, couponCode } = req.body;

    const order = await orderService.createOrder({
      userId: req.user.id,
      items,
      shippingAddress,
      couponCode,
    });

    res.status(201).json({ data: order });
  }
);

// Update order — validate both URL params and body
router.patch(
  '/orders/:id',
  authenticate,
  validate({ params: resourceIdSchema, body: updateOrderSchema }),
  async (req, res) => {
    const result = await prisma.order.updateMany({
      where: {
        id: req.params.id,
        userId: req.user.id,
        status: 'pending',
      },
      data: req.body,
    });

    if (result.count === 0) {
      return res.status(404).json({
        error: 'NOT_FOUND',
        message: 'Order not found or cannot be modified.',
      });
    }

    res.json({ message: 'Order updated.' });
  }
);

export default router;

Common Mistakes

1. No Authorization Check on Individual Resources (BOLA)

Wrong: Fetching a resource by ID without verifying the requesting user owns or has access to that resource. The handler calls prisma.order.findUnique({ where: { id: orderId } }) and returns whatever it finds. Any authenticated user can access any resource by changing the ID in the URL. This is the number one API vulnerability and the easiest to exploit.

Fix: Always include the current user's ID in the database query. Use prisma.order.findFirst({ where: { id: orderId, userId: currentUser.id } }) so the query only returns the resource if the requesting user owns it. Apply this pattern to every endpoint that accesses user-specific data — reads, updates, and deletes. For shared resources, implement explicit permission checks. Return 404 (not 403) when the resource is not found or not owned — do not reveal whether a resource exists to unauthorized users.

2. API Keys in URL Query Parameters

Wrong: Sending API keys as query parameters like GET /api/data?api_key=sk_live_abc123. The key appears in server access logs, browser history, proxy logs, CDN logs, referrer headers sent to third-party resources, and bookmark URLs. Anyone with access to any of these locations can steal the key. This is one of the most common sources of API key leakage.

Fix: Always send API keys in HTTP headers. Use X-API-Key: sk_live_abc123 or Authorization: ApiKey sk_live_abc123. Headers are not logged by default in most server configurations and are not included in referrer headers or browser history. Configure your logging infrastructure to redact authorization headers. On the server side, reject requests that include API keys in query parameters with a clear error message instructing the client to use headers instead.

3. No Rate Limiting on Public APIs

Wrong: Deploying API endpoints without any rate limiting. Attackers can make unlimited requests to scrape data, brute-force credentials, enumerate resources, or overwhelm the server. A login endpoint without rate limiting allows millions of password attempts. A list endpoint without limits enables complete data extraction. A search endpoint without limits enables denial of service through expensive queries.

Fix: Implement rate limiting on every endpoint. Use a Redis-backed rate limiter like rate-limiter-flexible for distributed rate limiting across multiple server instances. Set different limits for different operations: strict limits on login (5-10 per minute per IP), moderate limits on writes (30-60 per minute), generous limits on reads (100-500 per minute). Always return X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After headers so well-behaved clients can throttle themselves.

4. GraphQL Introspection Enabled in Production

Wrong: Leaving GraphQL introspection enabled in the production environment. Attackers send { __schema { types { name fields { name } } } } and receive a complete map of every type, field, argument, and enum in your API. This eliminates the need for any manual reconnaissance — the API self-documents its entire attack surface. Combined with tools like GraphQL Voyager, attackers can visualize your data model and identify sensitive fields and relationships instantly.

Fix: Disable introspection in production by setting introspection: false in your Apollo Server (or equivalent) configuration. Use an environment variable toggle: introspection: process.env.NODE_ENV !== 'production'. Keep introspection enabled in development and staging environments where developers need it. If external partners need your schema, provide a static SDL file or a documentation portal — do not expose live introspection.

5. Trusting Client-Provided IDs Without Ownership Verification

Wrong: Accepting a resource ID from the client (in the URL, body, or query parameters) and using it to perform operations without verifying the current user's relationship to that resource. For example, POST /api/orders with { "userId": "other-user-id", ... } creates an order attributed to another user. Or PATCH /api/orders/123 with { "status": "shipped" } changes the status of an order the user does not own.

Fix: Never trust client-provided user IDs for write operations. Always derive the user ID from the authenticated session or token — use req.user.id, not req.body.userId. For resource operations, always include the authenticated user's ID in the database query to enforce ownership. For admin operations that legitimately need to act on other users' resources, require explicit admin authorization checks and log the action for audit purposes.

6. Not Validating Webhook Signatures

Wrong: Processing webhook payloads without verifying the cryptographic signature. The webhook endpoint accepts any POST request and processes the data, trusting that only the legitimate provider would know the URL. Attackers can discover webhook URLs through source code leaks, configuration files, or network traffic analysis, and then send forged events to trigger arbitrary behavior — fake payments, fake order confirmations, fake notifications.

Fix: Always verify the webhook signature before processing any webhook data. Use the provider's official SDK when available (Stripe's constructEvent, GitHub's SDK). For manual verification, compute HMAC-SHA256 of the raw request body using the webhook secret, and compare to the signature header using crypto.timingSafeEqual. Validate the timestamp to prevent replay attacks. Use express.raw() middleware for webhook routes to access the raw body bytes — parsing and re-serializing JSON changes the bytes and breaks signature verification.

7. Returning Full Database Objects in API Responses

Wrong: Returning raw database objects directly in API responses, like res.json(user) where user is a Prisma or Mongoose model. The response includes every field in the database record: password hashes, internal flags, admin notes, related IDs, audit timestamps, soft-delete markers, and any other field that was never meant for client consumption. This is OWASP API3 — Broken Object Property Level Authorization.

Fix: Explicitly define response shapes. In Prisma, always use the select clause to specify exactly which fields to return. Create response DTOs or serializer functions that map database objects to API response shapes. Never pass a database object directly to res.json(). Use TypeScript types for response shapes and validate them at the API boundary. This also protects against accidentally exposing new fields when the database schema is updated — a new field is not exposed until it is explicitly added to the response shape.

8. No API Versioning Strategy

Wrong: Deploying API changes directly to existing endpoints without versioning. Adding required fields, changing response formats, renaming fields, or removing fields breaks existing clients. Alternatively, having no plan for deprecating old versions, leaving them running indefinitely with accumulating security vulnerabilities. Both extremes — no versioning and unlimited versioning — create security and maintenance problems.

Fix: Use URL-based versioning with major version numbers: /api/v1/, /api/v2/. Support at most 2-3 major versions simultaneously. Implement a sunset policy: announce deprecation, return Sunset and Deprecation headers, monitor usage, and eventually return 410 Gone. Apply security patches to all supported versions, not just the latest. Maintain an inventory of all API versions and endpoints. Use automated tests to ensure security middleware is applied consistently across all versions.

9. Exposing Internal Error Details in API Error Responses

Wrong: Returning detailed error information in production API responses. The response includes the full stack trace, the SQL query that failed, database table and column names, internal file paths, library versions, or the raw error message from a third-party service. This gives attackers a detailed map of your internal architecture, technology stack, and database schema. It also reveals which inputs cause errors, guiding injection attacks.

Fix: In production, return a generic error message, a machine-readable error code, and a unique request ID for support correlation. Log the full error details server-side with the request ID so support engineers can look up the details. Format production errors as: { "error": "INTERNAL_ERROR", "message": "An unexpected error occurred.", "requestId": "req_abc123" }. Use an error handling middleware that catches all unhandled errors and applies this formatting. In development, it is fine to include detailed error information.

10. Same Rate Limits for Anonymous and Authenticated Users

Wrong: Applying identical rate limits to anonymous and authenticated users. Anonymous users get the same generous limits as paying enterprise clients. Or, authenticated users get the same restrictive limits as anonymous users, frustrating legitimate high-volume clients. A single rate limit tier means you either allow too much anonymous abuse or restrict legitimate usage too aggressively.

Fix: Implement tiered rate limiting based on authentication status and subscription tier. Anonymous requests by IP address: 30 requests per minute (just enough for casual browsing). Free tier API key: 100 requests per minute. Pro tier: 500 requests per minute. Enterprise tier: 2000+ requests per minute with custom limits. Use the API key or JWT claims to determine the tier. Rate limit anonymous traffic by IP address. Rate limit authenticated traffic by API key or user ID. Return the current tier's limits in response headers so clients know their allocation. This incentivizes proper authentication and allows you to be strict with anonymous access while generous with paying customers.


See also: Secrets-Environment | Authentication-Identity | Backend-Security | Security-Headers-Infrastructure | Frontend-Security

Last reviewed: 2026-02


By Ryan Lind, Assisted by Claude Code and Google Gemini.

On this page