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. Before you go live, run through this checklist.
Authentication & Authorization
1. Use proper authentication on every endpoint
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).
2. Enforce authorization, not just authentication
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 the number one API vulnerability according to the OWASP API Security Top 10.
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.
3. Validate JWT tokens properly
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.
API Key Authentication
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:
- Never store raw keys. Store a SHA-256 hash and a prefix for display purposes.
- Show the key once. After creation, the raw key cannot be recovered.
- Support revocation. Every key should have a
revoked_attimestamp that you check on every request. - Scope keys narrowly. Give each key only the permissions it needs (e.g.,
scan:readvsscan:write). - Rotate regularly. Make it easy for users to create new keys and revoke old ones.
Input Validation
4. Validate all input on the server
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.
5. Sanitize output
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.
// 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.
Rate Limiting & Abuse Prevention
6. Rate limit every endpoint
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).
Always return 429 Too Many Requests with a Retry-After header so legitimate clients know when to retry.
7. Limit request body size
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.
Transport & Headers
8. Enforce HTTPS everywhere
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.
For a comprehensive walkthrough of all security-relevant HTTP headers, see our complete guide to security headers.
9. Configure CORS properly
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');
}
// If origin is not in the allowlist, no CORS headers are set,
// which means the browser will block the request.
return response;
}
Never reflect the Origin header back without validation. That is equivalent to Access-Control-Allow-Origin: * but worse because it also works with credentialed requests. For more details, read our CORS misconfiguration guide.
10. Add CSRF protection on mutating endpoints
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.
Webhook Security
If your API sends or receives webhooks, signature verification is critical. Without it, anyone can send forged payloads to your webhook endpoint.
Verifying incoming webhooks (e.g., from Stripe)
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 });
}
// Signature valid -- process the event
// IMPORTANT: Mark as processed AFTER business logic succeeds
await handleEvent(event);
return NextResponse.json({ received: true });
}
Signing outgoing webhooks
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:
- Use HMAC-SHA256 for signatures. Never use MD5 or plain SHA-1.
- Include a timestamp in the signature to prevent replay attacks. Reject payloads older than 5 minutes.
- Use the raw body for signature verification, not a re-serialized version (JSON key ordering differences will break the signature).
- Implement idempotency. Store processed event IDs and skip duplicates, because webhook providers retry on failure.
Error Handling & Logging
11. Return generic error messages
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
console.error('Database query failed', {
error: dbError.message,
stack: dbError.stack,
route: '/api/projects',
userId: user.id,
timestamp: new Date().toISOString(),
});
return NextResponse.json({ error: 'An internal error occurred' }, { status: 500 });
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.
12. Log security-relevant events
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 for user@example.com',
});
logSecurityEvent({
type: 'rate_limit',
ip: '203.0.113.42',
path: '/api/scan',
details: 'Exceeded 10 requests/minute',
});
Use structured JSON logging so your monitoring tools (Datadog, Grafana, CloudWatch) can parse and alert on these events. Set up alerts for:
- Spike in auth failures from a single IP (brute-force attack)
- Multiple 403s from an authenticated user (privilege escalation attempt)
- Rate limit hits from multiple IPs in a short window (distributed attack)
Before Launch
Run through each item. Fix the critical ones first. Automate what you can -- CI pipeline checks, automated security scans, and monitoring alerts catch regressions before your users do.
Visual Checklist Summary
Use this as a quick reference. Every item should be checked off before your API goes to production:
| # | Check | Priority | |---|-------|----------| | 1 | Authentication on every endpoint | Critical | | 2 | Authorization (BOLA) checks on resource access | Critical | | 3 | JWT signature verification with algorithm pinning | Critical | | 4 | Server-side input validation (Zod or equivalent) | Critical | | 5 | Output sanitization (allowlist fields) | High | | 6 | Rate limiting on all endpoints | High | | 7 | Request body size limits | Medium | | 8 | HTTPS enforced with HSTS header | Critical | | 9 | CORS restricted to your domains | High | | 10 | CSRF protection on mutating endpoints | High | | 11 | Generic error messages to clients | Medium | | 12 | Structured security event logging | Medium | | -- | Webhook signature verification | High | | -- | API key hashing and revocation | 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.
FAQ
How do I secure a REST API?
Securing a REST API requires a layered approach. Start with authentication (verify who is calling) and authorization (verify what they can access). Validate all input on the server using a schema library like Zod. Rate limit every endpoint to prevent abuse. Enforce HTTPS and configure CORS to restrict which domains can call your API. Return generic error messages so attackers cannot extract internal details. Finally, log security events so you can detect and respond to attacks. The 12-point checklist in this article covers every major area. For framework-specific guidance, see our CORS misconfiguration guide and CSRF protection guide.
Should I use API keys or OAuth?
It depends on the use case. API keys are best for server-to-server communication, CI/CD integrations, and third-party tool access (like MCP servers). They are simple to implement and easy for developers to use. OAuth 2.0 (and JWTs) are better for user-facing authentication where you need fine-grained scopes, token expiration, and refresh flows. Many APIs use both: OAuth for user sessions in the browser, and API keys for programmatic access. The critical rule for API keys is to never store the raw key -- hash it with SHA-256 and store the hash, showing the raw key to the user only once at creation time. For more on JWT pitfalls, read our JWT security guide.
How do I implement rate limiting?
The most common approach is a sliding window counter. Track the number of requests from each user or IP address within a rolling time window (e.g., 60 seconds). If the count exceeds the limit, return 429 Too Many Requests with a Retry-After header. For single-server deployments, an in-memory Map works. For distributed systems, use Redis with INCR and EXPIRE commands, or a database-backed solution. Set different limits for different endpoint types: authentication endpoints should be strict (5 requests/minute), while read-only data endpoints can be more generous (60 requests/minute). Always rate limit by authenticated user ID when possible (not just IP), because multiple users may share an IP behind a NAT or VPN.
What's the most common API vulnerability?
According to the OWASP API Security Top 10, Broken Object Level Authorization (BOLA) is the most common API vulnerability. It occurs when an API endpoint accepts a resource ID (like a project ID or user ID) from the client but does not verify that the authenticated user has permission to access that specific resource. An attacker simply changes the ID in the request to access another user's data. The fix is straightforward: always include a user_id check in your database query, or use Row Level Security policies that enforce access control at the database layer. Every endpoint that takes a resource identifier must verify ownership -- no exceptions.
Secure your API before launch. Scan your site now with CheckVibe's automated security checks -- covering CORS, headers, authentication, and more.