API Security Guide 2026 — OWASP Top 10, JWT, CORS, and Rate Limiting

Sanjeev SharmaSanjeev Sharma
5 min read

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)

#RiskExample
API1Broken Object Level AuthorizationUser A accesses User B's data
API2Broken AuthenticationWeak JWT secrets, no expiry
API3Broken Object Property Level AuthReturning passwordHash in responses
API4Unrestricted Resource ConsumptionNo pagination, no rate limits
API5Broken Function Level AuthorizationRegular user calls admin endpoint
API6Unrestricted Access to Sensitive FlowsNo CAPTCHA on password reset
API7Server-Side Request ForgeryUser-controlled URL fetched server-side
API8Security MisconfigurationDebug mode in production, CORS *
API9Improper Inventory ManagementUndocumented v1 endpoints still live
API10Unsafe API ConsumptionTrusting 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 &lt; 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 specify HS256 or RS256
  • 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; use httpOnly cookies

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 audit and snyk in 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 .env committed 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 * with credentials: true is 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading