API Security Best Practices: A Developer Guide for 2026
Author
ZTABS Team
Date Published
Every API you expose is an attack surface. In 2026, APIs account for over 80% of web traffic, and API-related breaches have become the most common attack vector for data theft. The good news: most API vulnerabilities are preventable with well-known patterns, and investing in security early reduces overall API development costs.
This guide covers the security layers every production API needs, with working code you can adapt to your stack.
Authentication: Proving Identity
Authentication answers the question: who is making this request? The two dominant patterns in 2026 are OAuth 2.0 with JWTs and API keys.
OAuth 2.0 and OpenID Connect
OAuth 2.0 is the industry standard for delegated authorization. OpenID Connect (OIDC) adds an identity layer on top. Together they handle login, consent, and token issuance.
For most applications, use the Authorization Code flow with PKCE — it works for both server-rendered apps and SPAs without exposing secrets to the browser.
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
);
export async function middleware(request: NextRequest) {
const token = request.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 401 });
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.sub as string);
requestHeaders.set("x-user-roles", JSON.stringify(payload.roles));
return NextResponse.next({ request: { headers: requestHeaders } });
} catch {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
}
JWT Best Practices
JWTs are powerful but easy to misuse. Follow these rules in every implementation.
| Practice | Why It Matters |
|----------|---------------|
| Validate iss, aud, and exp claims | Prevents token reuse across services |
| Use asymmetric signing (RS256/ES256) | Private key stays on the auth server |
| Keep tokens short-lived (15 min) | Limits damage from stolen tokens |
| Use refresh tokens for long sessions | Avoids long-lived access tokens |
| Never store JWTs in localStorage | Vulnerable to XSS; use httpOnly cookies |
| Rotate signing keys regularly | Limits blast radius of key compromise |
import { SignJWT } from "jose";
async function issueAccessToken(
userId: string,
roles: string[],
privateKey: KeyLike
) {
return new SignJWT({ sub: userId, roles })
.setProtectedHeader({ alg: "ES256" })
.setIssuedAt()
.setIssuer("https://auth.example.com")
.setAudience("https://api.example.com")
.setExpirationTime("15m")
.sign(privateKey);
}
API Key Management
API keys are simpler than OAuth but offer weaker security guarantees. Use them for server-to-server communication, not for end-user authentication.
import { createHash, timingSafeEqual } from "crypto";
function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
function validateApiKey(providedKey: string, storedHash: string): boolean {
const providedHash = Buffer.from(hashApiKey(providedKey));
const expected = Buffer.from(storedHash);
if (providedHash.length !== expected.length) return false;
return timingSafeEqual(providedHash, expected);
}
Always hash API keys before storing them. Use timingSafeEqual to prevent timing attacks during comparison. Prefix keys with a service identifier (e.g., ztabs_live_) so leaked keys are easy to identify and revoke.
Authorization: Enforcing Permissions
Authentication tells you who. Authorization tells you what they can do. Never conflate the two.
Role-Based Access Control (RBAC)
interface Permission {
resource: string;
actions: string[];
}
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [{ resource: "*", actions: ["*"] }],
editor: [
{ resource: "posts", actions: ["read", "create", "update"] },
{ resource: "media", actions: ["read", "create", "delete"] },
],
viewer: [
{ resource: "posts", actions: ["read"] },
{ resource: "media", actions: ["read"] },
],
};
function authorize(
userRoles: string[],
resource: string,
action: string
): boolean {
return userRoles.some((role) => {
const permissions = ROLE_PERMISSIONS[role] ?? [];
return permissions.some(
(p) =>
(p.resource === "*" || p.resource === resource) &&
(p.actions.includes("*") || p.actions.includes(action))
);
});
}
Object-Level Authorization
The most common API vulnerability (OWASP API #1) is Broken Object-Level Authorization — where a user can access another user's data by changing an ID in the URL.
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const userId = request.headers.get("x-user-id");
const document = await db.documents.findUnique({
where: { id: params.id },
});
if (!document) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (document.ownerId !== userId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(document);
}
Return 404 instead of 403 for unauthorized access — a 403 confirms the resource exists, which leaks information to attackers.
Rate Limiting
Without rate limiting, a single attacker can exhaust your resources, scrape your data, or brute-force credentials.
Token Bucket Algorithm
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, "60 s"),
analytics: true,
prefix: "api:ratelimit",
});
export async function rateLimitMiddleware(request: NextRequest) {
const identifier =
request.headers.get("x-api-key") ??
request.headers.get("x-forwarded-for") ??
"anonymous";
const { success, limit, remaining, reset } =
await ratelimit.limit(identifier);
if (!success) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
"Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
}
return null;
}
Rate Limiting Strategy
| Endpoint Type | Limit | Window | Rationale | |---------------|-------|--------|-----------| | Public read | 100 req | 1 min | Prevent scraping | | Authenticated read | 500 req | 1 min | Higher trust | | Write operations | 30 req | 1 min | Prevent spam | | Login/auth | 5 req | 15 min | Brute-force protection | | Password reset | 3 req | 1 hr | Abuse prevention |
Input Validation
Never trust client input. Validate every field, on every request, on the server.
Schema Validation with Zod
import { z } from "zod";
const createUserSchema = z.object({
email: z.string().email().max(254).toLowerCase(),
name: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z\s\-']+$/),
password: z.string().min(12).max(128),
role: z.enum(["viewer", "editor"]).default("viewer"),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = createUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: "Validation failed",
details: result.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
})),
},
{ status: 400 }
);
}
const validated = result.data;
// Safe to use validated data
}
SQL Injection Prevention
Use parameterized queries — always. ORMs like Prisma and Drizzle do this by default, but raw queries require explicit parameterization.
// DANGEROUS: string interpolation
const users = await db.$queryRawUnsafe(
`SELECT * FROM users WHERE email = '${email}'`
);
// SAFE: parameterized query
const users = await db.$queryRaw`
SELECT * FROM users WHERE email = ${email}
`;
Request Size Limits
export async function POST(request: NextRequest) {
const contentLength = parseInt(
request.headers.get("content-length") ?? "0"
);
if (contentLength > 1_048_576) {
return NextResponse.json(
{ error: "Payload too large" },
{ status: 413 }
);
}
// Process the request
}
CORS Configuration
Cross-Origin Resource Sharing (CORS) controls which domains can call your API from a browser. A misconfigured CORS policy is an open door.
const ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
];
function getCorsHeaders(origin: string | null) {
const headers: Record<string, string> = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Credentials": "true",
};
if (origin && ALLOWED_ORIGINS.includes(origin)) {
headers["Access-Control-Allow-Origin"] = origin;
}
return headers;
}
| CORS Setting | Secure | Insecure |
|-------------|--------|----------|
| Access-Control-Allow-Origin | Specific origins | * with credentials |
| Access-Control-Allow-Methods | Only methods you support | * |
| Access-Control-Allow-Headers | Only headers you need | * |
| Access-Control-Max-Age | 86400 (preflight caching) | 0 (no caching) |
Never use Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true — browsers block this combination, but some server configurations silently reflect any origin, which is equally dangerous.
Security Headers
HTTP security headers are a free layer of defense. Set them globally on every response.
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-XSS-Protection", "0");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload"
);
response.headers.set("Permissions-Policy", "camera=(), microphone=()");
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
);
return response;
}
Essential Security Headers
| Header | Purpose | Recommended Value |
|--------|---------|-------------------|
| Strict-Transport-Security | Force HTTPS | max-age=63072000; includeSubDomains |
| X-Content-Type-Options | Prevent MIME sniffing | nosniff |
| X-Frame-Options | Prevent clickjacking | DENY |
| Content-Security-Policy | Control resource loading | Strict allowlist per directive |
| Referrer-Policy | Control referrer leakage | strict-origin-when-cross-origin |
| Permissions-Policy | Restrict browser features | Deny unused features |
Logging and Monitoring
Security without observability is security theater. Log every authentication event, authorization failure, and rate limit hit.
interface SecurityEvent {
timestamp: string;
type: "auth_success" | "auth_failure" | "rate_limit" | "authz_denied";
ip: string;
userId?: string;
resource: string;
details: Record<string, unknown>;
}
function logSecurityEvent(event: SecurityEvent) {
console.log(JSON.stringify({
...event,
timestamp: new Date().toISOString(),
severity: event.type === "auth_success" ? "INFO" : "WARN",
}));
}
Set up alerts for anomalous patterns: sudden spikes in 401/403 responses, logins from unusual geolocations, and rapid sequential requests from a single IP.
API Security Checklist
Use this as a pre-launch review for every API endpoint.
- Authentication — every endpoint requires valid credentials (except intentionally public ones)
- Authorization — object-level checks on every request, not just role checks
- Input validation — strict schemas with Zod, maximum field lengths, type coercion
- Rate limiting — per-user and per-IP limits, tighter on auth endpoints
- CORS — explicit origin allowlist, no wildcard with credentials
- Security headers — HSTS, CSP, X-Content-Type-Options on every response
- Transport — TLS everywhere, no HTTP fallback
- Logging — all auth events, all failures, structured and searchable
- Error handling — generic error messages, never expose stack traces or internal IDs
- Dependency auditing —
npm auditin CI, automated PR alerts for vulnerabilities
Getting Started
API security is a practice, not a product. The patterns in this guide cover the fundamentals, but every application has unique requirements based on its data sensitivity, compliance needs, and threat model.
If you need help securing your APIs or building secure web applications from scratch, reach out to our team. We design and implement security architectures that protect your data without slowing down your development velocity.
Secure by default. Verify everything. Trust nothing.
Explore Related Solutions
Need Help Building Your Project?
From web apps and mobile apps to AI solutions and SaaS platforms — we ship production software for 300+ clients.
Related Articles
Database Scaling Strategies: From Single Server to Global Scale
A practical guide to database scaling strategies for growing applications. Covers vertical and horizontal scaling, read replicas, sharding, connection pooling, caching layers, and partition strategies with real SQL examples.
8 min readGraphQL vs REST API: When to Use Each in 2026
A practical comparison of GraphQL and REST for modern applications. Covers over-fetching, the N+1 problem, schema stitching, performance tradeoffs, and clear guidance on when each approach wins.
9 min readMonorepo vs Polyrepo: Choosing the Right Repository Strategy in 2026
A practical comparison of monorepo and polyrepo architectures. Covers Turborepo, Nx, and pnpm workspaces, with guidance on build caching, dependency management, and when to use each approach.