An API security checklist should cover ten areas: authentication and authorization, input validation, rate limiting and abuse, secrets and API keys, TLS and transport, headers and CORS, error handling and logging, monitoring, webhooks, and versioning and deprecation. Work through each one before you expose an endpoint to the internet.
Your API is your application's front door. Every endpoint is a potential attack surface. A single unprotected route can expose user data, drain your infrastructure budget, or give attackers a foothold into your entire system. The checklist below is grouped the way you should audit: by control, not by route. Each group maps to categories in the OWASP API Security Top 10, the reference list of what actually gets APIs breached.
Copy this into your pre-launch ticket. Everything below it is the implementation detail for each line.
Authentication & Authorization
jwt.verify() -- algorithm pinned, issuer and expiry checkedInput Validation
Content-Type checked before parsingRate Limiting & Abuse
429 responses carry Retry-AfterSecrets & API Keys
NEXT_PUBLIC_* values are public-saferevoked_at you check on every requestTLS & Transport
includeSubDomains (add preload once every subdomain is HTTPS)Referer headersSecure, HttpOnly, SameSiteHeaders & CORS
Access-Control-Allow-Origin set from an allowlist, never * with credentialsOPTIONS handled explicitly; methods and headers restrictedVary: Origin set wherever CORS headers varyX-Content-Type-Options: nosniff and Cache-Control: no-store on API responsesError Handling & Logging
Monitoring
Webhooks
Versioning & Deprecation
/api/v1/...)Deprecation and Sunset response headersThis group is where the expensive breaches live. Three of the OWASP API Security Top 10 2023 categories sit here: API1 (Broken Object Level Authorization), API2 (Broken Authentication), and API5 (Broken Function Level Authorization).
Every API route that handles user data must verify the caller's identity. No exceptions. This includes endpoints you think are "internal" or "harmless" -- attackers will find them.
// Next.js App Router example
export async function GET(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Now proceed with the authenticated user
}
For Express applications, extract this into middleware so every route is covered by default:
// Express middleware example
function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: 'Unauthorized' });
}
}
// Apply to all routes by default
app.use('/api', requireAuth);
The principle here is deny-by-default. Routes should require authentication unless you explicitly opt them out (and you should have a very good reason for doing so).
Authentication tells you who the caller is. Authorization tells you what they can do. Don't confuse them. A user who is logged in should not automatically have access to every resource in your system.
// Bad: authenticated but no authorization check
const { data } = await supabase.from('projects').select().eq('id', projectId);
// Good: ensure the project belongs to the authenticated user
const { data } = await supabase.from('projects')
.select()
.eq('id', projectId)
.eq('user_id', user.id) // Authorization check
.single();
This is especially important for endpoints that take a resource ID as a parameter. Without the authorization check, any authenticated user can access any other user's data just by guessing or iterating IDs. This class of vulnerability is called Broken Object Level Authorization (BOLA), and it is API1 in the OWASP API Security Top 10 -- the first entry on the list.
Two details that make the difference between a real fix and a fake one:
if after the fetch. A post-fetch check is a second chance to forget it, and it still burns the read.404, not 403, for resources the caller doesn't own. A 403 confirms the ID exists, which turns your error codes into an enumeration oracle.If you use Supabase, Row Level Security (RLS) policies provide a second layer of defense at the database level. But never rely on RLS alone -- always check authorization in your application code too, because service-role clients bypass RLS entirely.
BOLA is about which record. Broken Function Level Authorization is about which operation. A viewer who can call DELETE /api/v1/projects/:id has a BFLA bug even if the ownership check passes. Keep the permission matrix in one place so a new route cannot quietly ship without one:
// lib/authz.ts
type Role = 'owner' | 'admin' | 'member' | 'viewer';
const CAN: Record<string, Role[]> = {
'project:read': ['owner', 'admin', 'member', 'viewer'],
'project:write': ['owner', 'admin', 'member'],
'project:delete': ['owner', 'admin'],
'member:invite': ['owner', 'admin'],
'billing:manage': ['owner'],
};
export function can(role: Role, action: keyof typeof CAN): boolean {
return CAN[action].includes(role);
}
// In the route handler
const membership = await getMembership(user.id, orgId);
if (!membership || !can(membership.role, 'project:delete')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
Read the role from a server-side membership record. Never read it from the request body, and never from a JWT claim the user can influence at signup.
If you're using JWTs, verify the signature, check the expiration, and validate the issuer. Don't just decode and trust. A common mistake is using jwt.decode() (which does not verify the signature) instead of jwt.verify().
import jwt from 'jsonwebtoken';
function validateToken(token: string) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'], // Restrict to expected algorithm
issuer: 'your-app-name', // Validate issuer claim
maxAge: '1h', // Reject tokens older than 1 hour
});
return { valid: true, payload: decoded };
} catch (err) {
return { valid: false, payload: null };
}
}
// NEVER do this -- it skips signature verification
// const decoded = jwt.decode(token);
Always pin the algorithm with the algorithms option. Without it, an attacker can craft a token using "alg": "none" and bypass signature verification entirely. For a deeper dive, see our guide on common JWT security mistakes.
One more thing JWTs don't give you for free: revocation. A signed token stays valid until it expires, so "log out everywhere" and "this account was compromised" need a server-side check. Keep access tokens short-lived (minutes, not days), and store a tokens_valid_after timestamp on the user that you compare against the token's iat claim.
Client-side validation is for UX. Server-side validation is for security. Every request body, query parameter, and path parameter must be validated. Use a schema validation library like Zod to make this declarative:
import { z } from 'zod';
// Define the expected shape
const CreateProjectSchema = z.object({
name: z.string().min(1).max(100),
url: z.string().url(),
backendType: z.enum(['supabase', 'firebase', 'convex', 'custom', 'none']),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const result = CreateProjectSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
);
}
// result.data is now typed and validated
const { name, url, backendType } = result.data;
}
For simpler cases, manual validation works:
// Validate UUIDs
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(projectId)) {
return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 });
}
// Validate enums
const VALID_STATUSES = ['active', 'paused', 'deleted'];
if (!VALID_STATUSES.includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
}
Never trust that input will be the type you expect. A field you expect to be a string could be an array, an object, or null. Zod catches these mismatches automatically.
Passing a request body straight into an update is how users promote themselves. If the client controls the object, the client controls the columns:
// Bad: whatever the client sends becomes columns
await supabase.from('profiles').update(body).eq('id', user.id);
// A client sends { plan: 'max', is_admin: true } and self-promotes.
// Good: a strict schema plus an explicit column map
const UpdateProfile = z.object({
displayName: z.string().min(1).max(80),
timezone: z.string().max(64),
}).strict(); // .strict() rejects unknown keys instead of silently dropping them
const parsed = UpdateProfile.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
await supabase.from('profiles').update({
display_name: parsed.data.displayName,
timezone: parsed.data.timezone,
}).eq('id', user.id);
The explicit column map is what actually saves you. Even if a future schema change forgets .strict(), the write still touches only the two fields you named.
Even if your database is clean, sanitize data before returning it to clients. Strip internal fields, remove sensitive data, and escape HTML in user-generated content. OWASP calls the failure mode API3, Broken Object Property Level Authorization: the caller is allowed to see the record, but not every field on it.
// Bad: returning the raw database row
return NextResponse.json(project);
// Good: explicitly select safe fields
return NextResponse.json({
id: project.id,
name: project.name,
url: project.url,
createdAt: project.created_at,
// Note: user_id, stripe_customer_id, internal_notes are NOT exposed
});
This practice protects you even if someone accidentally adds a sensitive column to a table. If you only return allowlisted fields, new columns never leak to the client.
The same rule applies in reverse: don't ship a field to the client just because a UI needs it sometimes. An admin-only field returned to every caller and hidden with CSS is a data leak with extra steps.
// Bad -- string concatenation, and now you have SQL injection
const { rows } = await pool.query(`SELECT * FROM projects WHERE slug = '${slug}'`);
// Good -- parameterized; the driver never treats input as SQL
const { rows } = await pool.query('SELECT * FROM projects WHERE slug = $1', [slug]);
Schema validation and parameterization are complementary, not redundant: validation decides whether the value is acceptable, parameterization decides whether it can be executed. Full detail in our SQL injection prevention guide.
Any endpoint that takes a URL from the user -- webhook targets, avatar imports, link previews, site scanners -- can be pointed at your own infrastructure. Cloud metadata endpoints and internal admin panels are the usual targets.
import dns from 'node:dns/promises';
const BLOCKED_V4 = [
/^127\./, /^10\./, /^192\.168\./, /^169\.254\./,
/^172\.(1[6-9]|2\d|3[01])\./, /^0\./,
];
export async function assertPublicUrl(input: string): Promise<string> {
const url = new URL(input);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Unsupported protocol');
}
const { address, family } = await dns.lookup(url.hostname);
if (family === 4 && BLOCKED_V4.some((re) => re.test(address))) {
throw new Error('Private address');
}
if (family === 6 && (address === '::1' || address.startsWith('fc') || address.startsWith('fe80'))) {
throw new Error('Private address');
}
return address;
}
Two things this snippet does not solve on its own, and both matter:
lookup), rather than re-resolving the hostname.redirect: 'manual' and re-validate every hop. A public URL that 302s to 169.254.169.254 defeats a check that only ran once.const contentType = req.headers.get('content-type') ?? '';
if (!contentType.startsWith('application/json')) {
return NextResponse.json({ error: 'Unsupported Media Type' }, { status: 415 });
}
A route that happily parses text/plain or form encoding can be hit by a plain HTML form from another origin, because those content types are "simple requests" that skip the CORS preflight entirely. Insisting on JSON forces a preflight, which your CORS allowlist then rejects.
OWASP API4 is Unrestricted Resource Consumption and API6 is Unrestricted Access to Sensitive Business Flows. The first is about your infrastructure, the second is about your business logic. You need both.
Without rate limiting, an attacker can brute-force passwords, enumerate users, or exhaust your API quotas. Use a sliding window approach: track requests per user/IP over a rolling time window.
Here is a practical implementation using an in-memory store (for single-server deployments) or Redis (for distributed):
// Simple in-memory sliding window rate limiter
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function checkRateLimit(
key: string,
limit: number,
windowMs: number
): { allowed: boolean; remaining: number } {
const now = Date.now();
const entry = rateLimitMap.get(key);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1 };
}
if (entry.count >= limit) {
return { allowed: false, remaining: 0 };
}
entry.count++;
return { allowed: true, remaining: limit - entry.count };
}
// Usage in a route handler
export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
const { allowed, remaining } = checkRateLimit(ip, 10, 60_000); // 10 req/min
if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Remaining': '0',
},
}
);
}
// Proceed with request...
}
For production, use a database-backed or Redis-backed solution. Set different limits for different endpoints: login and signup endpoints should have stricter limits (5/minute) than read-only data endpoints (60/minute).
Key the limit on the authenticated user ID whenever you have one, falling back to IP only for anonymous traffic. IP-only limiting punishes every user behind a corporate NAT while barely inconveniencing an attacker with a proxy pool.
Always return 429 Too Many Requests with a Retry-After header so legitimate clients know when to retry.
Accept only what you need. A 100MB JSON body shouldn't crash your server. This is a denial-of-service vector that is trivially easy to prevent.
// Next.js: check body size before parsing
export async function POST(req: NextRequest) {
const contentLength = req.headers.get('content-length');
if (contentLength && parseInt(contentLength) > 100_000) {
return NextResponse.json({ error: 'Request too large' }, { status: 413 });
}
const body = await req.json();
if (JSON.stringify(body).length > 10_000) {
return NextResponse.json({ error: 'Request too large' }, { status: 413 });
}
// Proceed...
}
// Express: use built-in body size limits
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
In Express, the limit option rejects oversized payloads before they are fully read into memory. In Next.js, you need to check manually since the App Router reads the full body by default.
?limit=1000000 is a free denial-of-service attack against your own database, and it does not need an attacker -- a well-meaning integration will find it for you.
const MAX_PAGE_SIZE = 100;
const raw = Number(searchParams.get('limit'));
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(raw, MAX_PAGE_SIZE) : 25;
const offset = Math.max(Number(searchParams.get('offset')) || 0, 0);
The same idea applies to any client-controlled cost multiplier: date ranges on analytics endpoints, include parameters that trigger joins, and GraphQL query depth. If the client can make one request cost a hundred times more than another, put a ceiling on it.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
try {
const res = await fetch(upstream, { signal: controller.signal, redirect: 'manual' });
// ...
} finally {
clearTimeout(timer);
}
Without a timeout, a slow upstream holds your handler open -- along with its database connection -- until the platform kills it. That is how one dependency's bad day becomes your outage. Set statement timeouts on long database queries for the same reason.
Some endpoints are cheap to call and expensive to honour: free-tier scans, email invitations, coupon redemptions, password reset emails, trial signups. A per-request rate limit does not stop someone who registers fifty accounts and stays under it.
Meter these flows against the account, the organization, and the payment instrument -- not the request. Add a captcha or an email-verification gate before the expensive part runs, and alert when a single account's usage curve stops looking like a customer's.
For machine-to-machine access (CI/CD pipelines, third-party integrations, MCP servers), API key authentication is often more appropriate than JWTs. Here is a secure pattern:
import crypto from 'crypto';
// --- Key generation (done once, at key creation time) ---
function generateApiKey(): { raw: string; hashed: string; prefix: string } {
const raw = `cvd_live_${crypto.randomBytes(32).toString('hex')}`;
const hashed = crypto.createHash('sha256').update(raw).digest('hex');
const prefix = raw.slice(0, 12);
return { raw, hashed, prefix };
}
// Store `hashed` and `prefix` in the database.
// Show `raw` to the user ONCE, then discard it.
// --- Key validation (done on every request) ---
async function validateApiKey(req: NextRequest) {
const header = req.headers.get('authorization');
if (!header?.startsWith('Bearer cvd_live_')) {
return null;
}
const raw = header.replace('Bearer ', '');
const hashed = crypto.createHash('sha256').update(raw).digest('hex');
const { data: key } = await supabase
.from('api_keys')
.select('id, user_id, scopes, revoked_at')
.eq('key_hash', hashed)
.is('revoked_at', null)
.single();
return key;
}
Key points for API key security:
revoked_at timestamp that you check on every request.scan:read vs scan:write).cvd_live_ prefix lets secret scanners -- and you -- spot the key in a log or a public repo.Looking up the key by its hash through a database index also sidesteps timing attacks. Anywhere you compare a secret string directly -- a webhook secret, a cron token, an internal service header -- use a constant-time comparison instead:
import { timingSafeEqual } from 'node:crypto';
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
}
In a Next.js app, anything reachable from client code ends up in the JavaScript bundle. The NEXT_PUBLIC_ prefix is not a security boundary -- it is a label reminding you the value is already public.
// Safe in client components -- published by design
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Server-only. Importing this module into a client component ships the key.
const stripeSecret = process.env.STRIPE_SECRET_KEY;
Two habits worth building: add import 'server-only' at the top of modules that read secrets so a bad import fails the build instead of shipping, and grep your production bundle for your own key prefixes before a launch.
# Catch secrets before they reach the remote
npx gitleaks detect --no-banner --redact
# Already committed? Search the whole history, not just HEAD
git log -p -S 'sk_live_' --all | head
Rewriting history does not un-leak a key. Anything that reached a remote -- especially a public one -- must be rotated at the provider; assume it was scraped within minutes. If you want a second opinion on what is already exposed, our exposed API key scanner checks a live site for keys reachable from the browser, which is the other half of the problem: keys that never touched Git but ship in every page load.
No exceptions. All API traffic must be encrypted in transit. Set Strict-Transport-Security to tell browsers to always use HTTPS, even if the user types http://.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
In your Next.js config or middleware, you can enforce this header on every response:
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
],
},
];
},
};
The preload directive submits your domain to browser preload lists, ensuring HTTPS is enforced even on the very first visit. The includeSubDomains directive ensures subdomains (like api.yourdomain.com) are also covered. Only add preload once every subdomain serves HTTPS -- removal from preload lists is slow.
For a comprehensive walkthrough of all security-relevant HTTP headers, see our complete guide to security headers.
Bad: GET /api/v1/reports?token=eyJhbGciOi...
Good: GET /api/v1/reports Authorization: Bearer eyJhbGciOi...
Query strings are recorded in web server access logs, CDN logs, browser history, and the Referer header sent to any third-party asset on the page. TLS protects the request in flight and does nothing about any of that. The same applies to password reset and magic-link tokens: make them single-use and short-lived, because you cannot stop them appearing in a log.
Session cookies need the full set of flags:
cookies().set('session', token, {
httpOnly: true, // not readable from JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // not sent on cross-site POSTs
path: '/',
maxAge: 60 * 60 * 8,
});
Finally, automate certificate renewal and alert on expiry. An expired certificate is a total outage that a two-line monitor would have caught two weeks earlier.
Access-Control-Allow-Origin: * is almost never what you want. It tells browsers that any website on the internet can make requests to your API and read the responses. Restrict it to your actual domains.
const ALLOWED_ORIGINS = [
'https://yourdomain.com',
'https://app.yourdomain.com',
];
export function withCORS(req: NextRequest, response: NextResponse) {
const origin = req.headers.get('origin');
if (origin && ALLOWED_ORIGINS.includes(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
response.headers.set('Access-Control-Max-Age', '86400');
response.headers.set('Vary', 'Origin');
}
// If origin is not in the allowlist, no CORS headers are set,
// which means the browser will block the request.
return response;
}
Handle the preflight explicitly rather than letting a catch-all middleware answer it:
export async function OPTIONS(req: NextRequest) {
const origin = req.headers.get('origin');
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
return new NextResponse(null, { status: 403 });
}
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
},
});
}
Three failure modes worth naming. Never reflect the Origin header back without validation -- that is equivalent to * but worse, because it also works with credentialed requests. Never match origins with startsWith or a substring test, because https://yourdomain.com.evil.example passes both. And always set Vary: Origin, or a shared cache can hand the allow-header minted for one origin to a request from another. For more details, read our CORS misconfiguration guide.
For session-based auth (cookies), validate the Origin header on POST/PUT/PATCH/DELETE requests. Without this, a malicious website can submit forms to your API while the user's session cookie is automatically attached by the browser.
// CSRF protection middleware
function csrfCheck(req: NextRequest): boolean {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return true; // Safe methods don't need CSRF checks
}
const origin = req.headers.get('origin');
if (!origin) {
return false; // Reject requests with no origin on mutating methods
}
const ALLOWED_ORIGINS = [
'https://yourdomain.com',
'https://app.yourdomain.com',
];
// In development, allow localhost
if (process.env.NODE_ENV === 'development' && origin === 'http://localhost:3000') {
return true;
}
return ALLOWED_ORIGINS.includes(origin);
}
export async function POST(req: NextRequest) {
if (!csrfCheck(req)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Proceed...
}
Note that API key authentication (Bearer tokens in the Authorization header) is inherently immune to CSRF because browsers do not automatically attach custom headers. CSRF is only a concern with cookie-based authentication.
For a full treatment of CSRF attacks and defenses, see our CSRF protection guide.
Security header advice usually targets HTML pages, and API routes get skipped. Two of them matter for JSON:
// next.config.ts
const nextConfig = {
poweredByHeader: false, // drop the framework version banner
async headers() {
return [
{
source: '/api/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Cache-Control', value: 'no-store' },
{ key: 'Referrer-Policy', value: 'no-referrer' },
],
},
];
},
};
Cache-Control: no-store is the one that bites hardest in production: a CDN or shared proxy that caches an authenticated JSON response will happily serve one user's data to the next. nosniff stops a browser from reinterpreting a JSON response as HTML or script, which is what turns a reflected value into stored XSS.
Never expose internal error details to clients. Stack traces, SQL query fragments, and database column names give attackers a roadmap of your system. Log the full error server-side, return a generic message to the client.
// Bad: exposes database error to client
return NextResponse.json({ error: dbError.message }, { status: 500 });
// Good: generic message, detailed server-side log, shared correlation id
const errorId = crypto.randomUUID();
console.error(JSON.stringify({
errorId,
message: dbError.message,
stack: dbError.stack,
route: '/api/projects',
userId: user.id,
timestamp: new Date().toISOString(),
}));
return NextResponse.json(
{ error: 'An internal error occurred', errorId },
{ status: 500 }
);
The errorId is what makes generic errors survive contact with a support queue: the user quotes an opaque UUID, you find the exact log line, and neither of you learns anything about your schema.
This applies to all error types: database errors, third-party API failures, validation errors (which should say what's invalid but not reveal schema internals), and unhandled exceptions. For authentication errors, always use the same generic message ("Invalid credentials") regardless of whether the username or password was wrong -- otherwise you enable account enumeration. Watch response timing too: bailing out early on an unknown email while hashing a password for a known one leaks the same information through the clock.
Log authentication attempts (success and failure), authorization failures, rate limit hits, and unusual patterns. When something goes wrong, you need to know what happened.
// Structured logging for security events
function logSecurityEvent(event: {
type: 'auth_success' | 'auth_failure' | 'authz_failure' | 'rate_limit' | 'suspicious';
userId?: string;
ip: string;
path: string;
details?: string;
}) {
console.log(JSON.stringify({
...event,
timestamp: new Date().toISOString(),
service: 'api',
}));
}
// Usage examples
logSecurityEvent({
type: 'auth_failure',
ip: req.headers.get('x-forwarded-for') ?? 'unknown',
path: '/api/auth/login',
details: 'Invalid password',
});
logSecurityEvent({
type: 'rate_limit',
ip: '203.0.113.42',
path: '/api/scan',
details: 'Exceeded 10 requests/minute',
});
Logs are also a place secrets go to leak. Redact by key name at the logging boundary, so a future engineer logging a whole request object cannot dump an Authorization header into your log aggregator:
const SENSITIVE = /(authorization|cookie|password|token|api[-_]?key|secret)/i;
function redact(obj: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, SENSITIVE.test(k) ? '[redacted]' : v])
);
}
OWASP API8 is Security Misconfiguration, and this is where most of it lives. Before launch, confirm each of these is off or unreachable in production: /api/debug and health endpoints that dump environment variables, GraphQL introspection and playground UIs, verbose framework error pages, directory listings, source maps served publicly, and any seeded test account. Grep your route list for the word "debug" and open every hit.
Logging tells you what happened. Monitoring tells you while it happens. The difference is whether an incident is a paragraph in a postmortem or a page at 3am.
Alert on the signals that only mean one thing:
# Illustrative alert rules -- tune every threshold to your own traffic baseline
- name: auth_failure_spike
query: count(type="auth_failure") by ip over 5m
action: page # credential stuffing or a brute-force run
- name: auth_failures_against_one_account
query: count(type="auth_failure") by user_id over 15m
action: notify # targeted account takeover attempt
- name: authz_denials_from_authenticated_user
query: count(type="authz_failure") by user_id over 10m
action: notify # someone is walking your object IDs
- name: rate_limit_hits_many_ips
query: count_distinct(ip) where type="rate_limit" over 5m
action: notify # distributed abuse, not one noisy client
- name: tls_expiry
query: days_until_cert_expiry("api.yourdomain.com")
action: page # under 14 days
The second and third rules are the ones teams skip, and they are the ones that catch BOLA probing. A legitimate user almost never generates a burst of 403s.
Run dependency and secret scanning on every pull request, not on a schedule someone eventually mutes:
# .github/workflows/security.yml
name: security
on: [pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks can only scan history it has
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm audit --audit-level=high
- uses: gitleaks/gitleaks-action@v2
Then monitor from outside. Internal metrics cannot see a broken TLS chain, a DNS change, or a CDN rule that started returning your API's error page with a 200. A synthetic check that authenticates and hits one real endpoint on a schedule catches all three.
If your API sends or receives webhooks, signature verification is critical. Without it, anyone can send forged payloads to your webhook endpoint -- and a webhook handler is, by design, an unauthenticated route that writes to your database.
Most providers ship a helper. Use it, and use the raw body:
import Stripe from 'stripe';
export async function POST(req: NextRequest) {
const body = await req.text(); // Raw body, not parsed
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
await handleEvent(event);
return NextResponse.json({ received: true });
}
When you are verifying a provider that does not ship a helper, this is the whole job -- note the timestamp check and the constant-time comparison:
import crypto from 'node:crypto';
const MAX_SKEW_SECONDS = 300;
function verifySignature(rawBody: string, header: string, secret: string): boolean {
// header looks like: t=1712345678,v1=9f86d081...
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=') as [string, string])
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > MAX_SKEW_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Providers retry. A retry after a partial failure must not grant the plan twice.
create table webhook_events (
id text primary key, -- the provider's event id
received_at timestamptz not null default now(),
processed_at timestamptz
);
const { error } = await supabase
.from('webhook_events')
.insert({ id: event.id });
if (error?.code === '23505') {
// Primary key conflict: we have seen this event already.
return NextResponse.json({ received: true, duplicate: true });
}
await handleEvent(event);
await supabase
.from('webhook_events')
.update({ processed_at: new Date().toISOString() })
.eq('id', event.id);
Insert first, process, then stamp processed_at. If the handler crashes halfway, the row exists but is unstamped -- which is exactly the state a reconciliation job should look for.
When your API dispatches webhooks to user-configured URLs, sign the payload so recipients can verify authenticity:
import crypto from 'crypto';
function signWebhookPayload(payload: string, secret: string): string {
const timestamp = Math.floor(Date.now() / 1000);
const signatureInput = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signatureInput)
.digest('hex');
return `t=${timestamp},v1=${signature}`;
}
// When dispatching a webhook
const payload = JSON.stringify(eventData);
const signature = signWebhookPayload(payload, webhookSecret);
await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
},
body: payload,
});
Key practices for webhook security:
OWASP API9 is Improper Inventory Management, and it is the least glamorous entry on the list. The endpoint that breaches you is rarely the one you were thinking about -- it is the v1 route you replaced eighteen months ago and never turned off.
Version every public route. /api/v1/projects costs nothing today and saves you from a breaking change you cannot ship later.
Generate the inventory, don't maintain it. A hand-written list goes stale in a sprint:
# Next.js App Router: every deployed API route, straight from the filesystem
find src/app/api -name 'route.ts' | sed 's|src/app||; s|/route.ts||' | sort
Diff that output in CI against a checked-in snapshot, and a new public endpoint cannot ship without someone acknowledging it.
Prove a version is dead before you remove it, then actually remove it. Log the version on every request so you can answer the question with data:
select api_version,
count(*) as calls,
count(distinct user_id) as callers,
max(created_at) as last_seen
from api_requests
where created_at > now() - interval '30 days'
group by 1
order by 2 desc;
Announce deprecations in the response, not only in a changelog. Clients read headers; nobody reads changelogs:
Deprecation: @1790812799
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.example.com/api/v2-migration>; rel="deprecation"
Both headers are standardised: Sunset in RFC 8594, Deprecation in RFC 9745. Note the value formats differ. Deprecation is a structured-field date -- an @ followed by a Unix timestamp marking when the endpoint became (or becomes) deprecated. Sunset is an HTTP-date marking when it stops responding, and RFC 9745 requires it to be no earlier than the deprecation date. The deprecation link relation points at the human-readable migration guide.
Keep non-production hosts off the public internet. staging-api.yourdomain.com typically runs older code with debug enabled against a copy of real data. Put it behind IP allowlisting or SSO.
Validate what third parties send you (OWASP API10). Unsafe Consumption of APIs is the mirror image of input validation: you trust your vendor's response more than you trust your users, and a compromised or merely sloppy upstream feeds you an unexpected type, an oversized payload, or a redirect to an internal address.
const UpstreamSchema = z.object({
id: z.string(),
status: z.enum(['ok', 'error']),
});
const parsed = UpstreamSchema.safeParse(await res.json());
if (!parsed.success) {
throw new Error('Upstream returned an unexpected shape');
}
API security testing works in layers, cheapest first. The goal is that every item above has something automated watching it, so a regression fails a build instead of surfacing in a bug bounty report.
1. Authorization matrix tests in CI. This is the highest-value test suite you can write, because it covers the highest-ranked OWASP category. For every route that takes a resource ID, assert two things: anonymous gets 401, and a non-owner gets 403 or 404.
// tests/authz.spec.ts -- add a row here whenever you add a resource route
const ROUTES = [
{ method: 'GET', path: (id: string) => `/api/v1/projects/${id}` },
{ method: 'PATCH', path: (id: string) => `/api/v1/projects/${id}` },
{ method: 'DELETE', path: (id: string) => `/api/v1/projects/${id}` },
];
for (const route of ROUTES) {
it(`${route.method} rejects an anonymous caller`, async () => {
const res = await fetch(BASE + route.path(userAProjectId), { method: route.method });
expect(res.status).toBe(401);
});
it(`${route.method} rejects a non-owner`, async () => {
const res = await fetch(BASE + route.path(userAProjectId), {
method: route.method,
headers: { Authorization: `Bearer ${userBToken}` },
});
expect([403, 404]).toContain(res.status);
});
}
2. Negative probes with curl. Fast to write, easy to run against a preview deployment. Each of these should fail:
API=https://api.example.com
curl -s -o /dev/null -w '%{http_code} no-auth\n' "$API/v1/projects"
curl -s -o /dev/null -w '%{http_code} wrong-owner\n' -H "Authorization: Bearer $TOKEN_B" "$API/v1/projects/$A_ID"
curl -s -o /dev/null -w '%{http_code} huge-body\n' -X POST -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' --data-binary @10mb.json "$API/v1/projects"
curl -s -o /dev/null -w '%{http_code} bad-origin\n' -X POST -H 'Origin: https://evil.example' "$API/v1/projects"
curl -s -o /dev/null -w '%{http_code} alg-none\n' -H "Authorization: Bearer $ALG_NONE_TOKEN" "$API/v1/projects"
Expect 401, 404 or 403, 413, 403, 401. A 200 anywhere in that list is a finding.
3. Confirm the rate limiter actually fires. Limits that were never tested are limits that were misconfigured:
for i in $(seq 1 30); do
curl -s -o /dev/null -w '%{http_code} ' -X POST "$API/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"probe@example.com","password":"wrong"}'
done; echo
# You want to see 429s well before the 30th request.
4. Dependency and secret scanning on every pull request -- the CI workflow in the Monitoring section above.
5. External scanning against the deployed API. Black-box tools see what an attacker sees: response headers, CORS behaviour, exposed debug routes, expired certificates, and information leaked in error pages. This is the layer that catches configuration drift between your next.config.ts and whatever your CDN is actually returning.
6. Manual review of anything that touches money, permissions, or other people's data. Automated tests check the rules you thought to write down. A second pair of eyes on the diff catches the rule you didn't.
Use this as a quick reference. Every item should be checked off before your API goes to production, but if you are triaging, work top to bottom.
| # | Check | Priority |
|---|---|---|
| 1 | Authentication on every endpoint | Emergency |
| 2 | Authorization (BOLA) checks on resource access | Emergency |
| 3 | JWT signature verification with algorithm pinning | Emergency |
| 4 | Server-side input validation (Zod or equivalent) | Emergency |
| 5 | Output sanitization (allowlist fields) | Critical |
| 6 | Rate limiting on all endpoints | Critical |
| 7 | Request body size limits | High |
| 8 | HTTPS enforced with HSTS header | Emergency |
| 9 | CORS restricted to your domains | Critical |
| 10 | CSRF protection on mutating endpoints | Critical |
| 11 | Generic error messages to clients | High |
| 12 | Structured security event logging | High |
| -- | Function-level authorization on privileged routes | Emergency |
| -- | Mass-assignment protection on writes | Critical |
| -- | SSRF validation on user-supplied URLs | Critical |
| -- | Webhook signature verification and idempotency | Critical |
| -- | API key hashing, scoping, and revocation | Critical |
| -- | Secrets kept out of client bundles and Git history | Emergency |
| -- | Debug routes and introspection off in production | Critical |
| -- | Endpoint inventory, including old versions and staging | High |
If you want to check your API security posture automatically, run a free scan with CheckVibe. It tests for misconfigured CORS, missing security headers, exposed debug endpoints, and more across your entire application.
Authorization -- specifically, checking that the authenticated caller owns the exact resource they requested. Authentication is table stakes, but OWASP ranks Broken Object Level Authorization first in the API Security Top 10 because it is trivially exploitable: change an ID in the URL and read someone else's data. Every endpoint that accepts a resource identifier must verify ownership inside the query itself.
BOLA happens when an endpoint accepts a resource ID from the client and returns the record without checking who owns it. An attacker changes /projects/123 to /projects/124 and reads another tenant's data. It is API1 in the OWASP API Security Top 10. The fix is to scope every query by the authenticated user or organization, and to back that up with row-level security in the database.
Test in layers. Run an authorization matrix in CI: for every resource route, assert that an anonymous caller gets 401 and a non-owner gets 403 or 404. Add negative probes for oversized bodies, disallowed origins, and unsigned JWTs. Run dependency and secret scanning on every pull request. Then scan the deployed API from outside for headers, CORS, and exposed debug routes.
Work through the ten groups in this REST API security checklist: authentication and authorization, input validation, rate limiting, secrets and API keys, TLS, headers and CORS, error handling and logging, monitoring, webhooks, and versioning. Deny by default, validate every input against a schema, scope every query to the caller, and keep a generated inventory of every endpoint you have deployed.
Usually both. API keys suit server-to-server callers -- CI pipelines, integrations, MCP servers -- because they are simple and long-lived. OAuth and JWTs suit user-facing sessions where you need scopes, short expiry, and refresh flows. Whichever you pick, store API keys as SHA-256 hashes with a display prefix, show the raw key exactly once, and make revocation a single field you check on every request.
Use a sliding window keyed on the authenticated user ID, falling back to IP for anonymous traffic -- IP alone punishes everyone behind a shared NAT. In-memory counters work on a single instance; move to Redis with INCR and EXPIRE, or a database counter, once you run more than one. Return 429 with a Retry-After header, and set far stricter limits on login, signup, and password reset.
Secure your API before launch. Scan your site now with CheckVibe's automated security checks -- covering CORS, headers, authentication, and more.
Paste your URL and get a security report in 30 seconds — 100+ automated checks with AI-ready fix prompts.
Scan your site freeRelated articles
How CSRF attacks work and how to prevent them. Covers CSRF tokens, SameSite cookies, custom headers, and framework-specific protection for Next.js, Express, and Django.
The 7 most dangerous JWT security mistakes developers make. Algorithm confusion, weak secrets, missing expiration, and more — with code examples showing how to fix each one.
Step-by-step security checklist for Next.js apps with Supabase. Covers RLS policies, API key exposure, auth hardening, security headers, and common mistakes.