API Security Guide 2026 — OWASP Top 10, JWT, CORS, and Rate Limiting
Advertisement
Introduction
Why This Matters
APIs are the primary attack surface for web applications in 2026. The OWASP API Security Top 10 identifies the most critical vulnerabilities — many of which are still found in production APIs that were reviewed just months ago. Security is not a feature you add later; it must be built in from day one.
OWASP API Security Top 10 (2023)
| # | Risk | Example |
|---|---|---|
| API1 | Broken Object Level Authorization | User A accesses User B's data |
| API2 | Broken Authentication | Weak JWT secrets, no expiry |
| API3 | Broken Object Property Level Auth | Returning passwordHash in responses |
| API4 | Unrestricted Resource Consumption | No pagination, no rate limits |
| API5 | Broken Function Level Authorization | Regular user calls admin endpoint |
| API6 | Unrestricted Access to Sensitive Flows | No CAPTCHA on password reset |
| API7 | Server-Side Request Forgery | User-controlled URL fetched server-side |
| API8 | Security Misconfiguration | Debug mode in production, CORS * |
| API9 | Improper Inventory Management | Undocumented v1 endpoints still live |
| API10 | Unsafe API Consumption | Trusting third-party API data without validation |
JWT Best Practices
// src/lib/jwt.ts
import { SignJWT, jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
if (process.env.JWT_SECRET!.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters')
}
export async function signToken(payload: {
sub: string
role: string
}): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // short-lived access tokens
.setIssuer('yoursite.com')
.setAudience('api.yoursite.com')
.sign(secret)
}
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
issuer: 'yoursite.com',
audience: 'api.yoursite.com',
})
return payload
}// Refresh token rotation
export async function refreshAccessToken(refreshToken: string) {
const session = await getSession(refreshToken)
if (!session || session.expiresAt < Date.now()) {
throw new UnauthorizedError('Refresh token expired')
}
// Rotate: invalidate old refresh token, issue new one
await deleteSession(refreshToken)
const newRefreshToken = await createSession(session.userId)
const accessToken = await signToken({ sub: session.userId, role: session.role })
return { accessToken, refreshToken: newRefreshToken }
}Input Validation and Injection Prevention
// Always validate and sanitize
import { z } from 'zod'
import { escape } from 'sqlstring'
const SearchSchema = z.object({
q: z.string().max(200).transform(s => s.trim()),
category: z.enum(['posts', 'users', 'products']),
page: z.coerce.number().int().min(1).max(100).default(1),
})
// Use parameterized queries — NEVER string interpolation
// WRONG:
const unsafe = `SELECT * FROM posts WHERE title = '${userInput}'`
// CORRECT with Prisma:
const safe = await prisma.post.findMany({
where: { title: { contains: userInput } },
})
// CORRECT with raw SQL (parameterized):
const safeRaw = await prisma.$queryRaw`
SELECT * FROM posts WHERE title ILIKE ${'%' + userInput + '%'}
LIMIT 20
`CORS Configuration
// src/middleware/cors.ts
import Fastify from 'fastify'
import cors from '@fastify/cors'
const ALLOWED_ORIGINS = [
'https://yoursite.com',
'https://app.yoursite.com',
...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []),
]
await app.register(cors, {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) return callback(null, true)
if (ALLOWED_ORIGINS.includes(origin)) {
callback(null, true)
} else {
callback(new Error('CORS: origin not allowed'))
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-RateLimit-Remaining', 'X-RateLimit-Reset'],
maxAge: 86400, // 24 hours preflight cache
})Object-Level Authorization (API1)
// WRONG: trusts user-supplied ID without checking ownership
app.get('/api/orders/:id', async (req) => {
return prisma.order.findUnique({ where: { id: req.params.id } }) // IDOR!
})
// CORRECT: always scope to the authenticated user
app.get('/api/orders/:id', { preHandler: [requireAuth] }, async (req) => {
const order = await prisma.order.findUnique({
where: { id: req.params.id, userId: req.user.id }, // scoped!
})
if (!order) throw new NotFoundError('Order', req.params.id)
return order
})Security Headers
// Apply security headers on every response
app.addHook('onSend', async (_, reply) => {
reply.header('X-Content-Type-Options', 'nosniff')
reply.header('X-Frame-Options', 'DENY')
reply.header('X-XSS-Protection', '1; mode=block')
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
reply.header(
'Content-Security-Policy',
"default-src 'self'; img-src 'self' data: https:; script-src 'self'"
)
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
})Common Mistakes
- Using
algorithm: 'none'in JWT — allows forged tokens; always specifyHS256orRS256 - Returning full database objects in API responses — exposes
passwordHash, internal IDs, etc. - Setting
Access-Control-Allow-Origin: *for credentialed requests — browsers block this anyway - Trusting user-supplied IDs for resource access without checking ownership (IDOR)
- Storing refresh tokens in
localStorage— XSS can steal them; usehttpOnlycookies
Best Practices
- Scope every data query to the authenticated user's ID — never fetch by ID alone
- Use short-lived access tokens (15 minutes) with rotating refresh tokens (7 days)
- Validate all inputs with Zod at the API boundary before touching the database
- Use parameterized queries exclusively — never concatenate user input into SQL
- Run
npm auditandsnykin CI to catch known vulnerabilities in dependencies
Key Takeaways
- IDOR (Broken Object Level Authorization) is the most common API vulnerability — always scope queries to
userId - JWT secrets must be at least 32 characters long and stored in a secrets manager, not
.envcommitted to git - Access tokens should expire in 15 minutes; rotating refresh tokens reduce the window for token theft
- Parameterized queries completely prevent SQL injection — string interpolation never should be used
- CORS
*withcredentials: trueis rejected by browsers — always specify exact allowed origins - Security headers (
CSP,HSTS,X-Frame-Options) provide defense-in-depth at the HTTP layer - Input validation with Zod at every API boundary prevents type coercion and injection attacks
- Rate limiting by IP and user ID prevents brute-force and credential stuffing attacks
Advertisement
Related reading
JWT Authentication with TypeScript — Secure Implementation 20266 min readAPI Security in 2026 — OWASP Top 10 Updated for AI and Modern Backends8 min readClock Skew Breaking Tokens — When Servers Disagree on What Time It Is6 min readOAuth 2.0 with Node.js and TypeScript — PKCE and Authorization Code Guide 20266 min readPassport.js with TypeScript — Authentication Strategies Guide 20265 min readJWT vs Session Tokens — Choosing the Right Authentication Strategy in 20268 min read