Skip to content
Learni
View all tutorials
Sécurité Web

How to Secure an API Against OWASP Top 10 in 2026

Lire en français

Introduction

The OWASP API Security Top 10 (2023) lists the most critical security risks for REST and GraphQL APIs, responsible for 94% of API breaches. In 2026, with the explosion of microservices and serverless apps, ignoring these vulnerabilities leaves your backend open to attacks like data theft or DoS. This intermediate tutorial guides you step-by-step to secure a Next.js App Router API against the top risks: Broken Object Level Authorization (BOLA), Broken Authentication, Excessive Data Exposure, Lack of Rate Limiting, Broken Function Level Authorization, and an overview of the others.

We use an in-memory store simulating a database for 100% functional, copy-paste-ready examples. Each step includes vulnerable code first, then the secured version, with precise explanations of mechanisms (RBAC, JWT, middleware). At the end, your API will resist OWASP ZAP scans. Ideal for backend devs aiming to go pro in API security. Estimated time: 30 minutes.

Prerequisites

  • Node.js 20+ installed
  • Knowledge of TypeScript and Next.js 15 (App Router)
  • Editor like VS Code with TypeScript extension
  • Testing tools: Postman or curl
  • npm or yarn for dependencies

Initializing the Next.js Project

terminal
npx create-next-app@latest mon-api-securisee --typescript --app --tailwind --eslint --src-dir --import-alias "@/*"
cd mon-api-securisee
npm install jsonwebtoken @types/jsonwebtoken zod @types/node
npm install -D @types/bun
next dev

This command creates a Next.js 15 project with TypeScript and App Router. We add jsonwebtoken for JWT auth, zod for validation, and Node types. Run next dev to start on http://localhost:3000. Pitfall: Don't forget --app for App Router, or legacy API routes won't work.

In-Memory Data Store (Database Simulation)

Before routes, let's create a simulated DB module using a Map for users. Each user has an id, ownerId (for BOLA), email, and role (admin/user for function-level auth). This mimics Prisma without external DB setup, perfect for local testing.

Creating the In-Memory DB

src/lib/db.ts
import { NextApiRequest, NextApiResponse } from 'next';

type UserRole = 'admin' | 'user';

export interface User {
  id: string;
  name: string;
  email: string;
  role: UserRole;
  ownerId: string;
}

export const users = new Map<string, User>();

// Test data
users.set('1', { id: '1', name: 'Alice Admin', email: 'alice@ex.com', role: 'admin', ownerId: 'alice' });
users.set('2', { id: '2', name: 'Bob User', email: 'bob@ex.com', role: 'user', ownerId: 'bob' });
users.set('3', { id: '3', name: 'Charlie User', email: 'charlie@ex.com', role: 'user', ownerId: 'eve' });

export function getUserById(id: string): User | undefined {
  return users.get(id);
}

export function getUsersByOwner(ownerId: string): User[] {
  return Array.from(users.values()).filter(u => u.ownerId === ownerId);
}

This module defines the User interface with ownerId key for BOLA. Functions getUserById and getUsersByOwner simulate DB queries. Add it to src/lib/. Pitfall: Map is global (shared state), great for demos but use Redis/Prisma in production for persistence.

API1:2023 - Broken Object Level Authorization (BOLA)

BOLA (formerly TOP1) allows access to unauthorized objects via ID manipulation (e.g., /users/123 instead of your own users). It causes 44% of API breaches. Analogy: Like reading your neighbor's mail by changing the address. Solution: Verify ownership by user ID on every GET/PUT/DELETE.

Vulnerable Route to BOLA

src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getUserById } from '@/lib/db';

export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
  const id = params.id;
  const user = getUserById(id);
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }
  return NextResponse.json(user);
}

export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
  const id = params.id;
  const user = getUserById(id);
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }
  // TODO: Delete in production
  return NextResponse.json({ message: 'User deleted' });
}

This vulnerable route returns/deletes any user via ID without owner checks. Test with curl http://localhost:3000/api/users/2: anyone can access Bob. Pitfall: No 405 for unauthorized methods; add them explicitly.

Route Secured Against BOLA

src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getUserById } from '@/lib/db';
import { getUserIdFromToken } from '@/lib/auth';

export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
  const token = request.headers.get('Authorization')?.replace('Bearer ', '');
  const userId = token ? getUserIdFromToken(token) : null;
  const id = params.id;
  const user = getUserById(id);
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }
  if (user.ownerId !== userId && userId !== 'admin') {
    return NextResponse.json({ error: 'Access denied' }, { status: 403 });
  }
  return NextResponse.json({ ...user, sensitive: '[redacted]' });
}

export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
  const token = request.headers.get('Authorization')?.replace('Bearer ', '');
  const userId = token ? getUserIdFromToken(token) : null;
  const id = params.id;
  const user = getUserById(id);
  if (!user || (user.ownerId !== userId && userId !== 'admin')) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }
  // users.delete(id); // In production
  return NextResponse.json({ message: 'User deleted' });
}

Now we extract userId from the JWT and check user.ownerId === userId or admin. Redact sensitive data. Test: Bob's JWT only accesses his users. Pitfall: Always validate token before DB query to avoid timing attacks.

API2:2023 - Broken User Authentication

Broken auth includes weak passwords, infinite sessions, or JWTs without expiry. 94% of apps are vulnerable. Solution: JWTs with short expiry, refresh tokens, and strict validation. We'll implement a simple login.

JWT Auth Middleware and Login Functions

src/lib/auth.ts
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET || 'supersecret2026';

export interface JWTPayload {
  userId: string;
  role: string;
  exp: number;
}

export function createToken(userId: string, role: string): string {
  return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: '15m' });
}

export function getUserIdFromToken(token: string): string | null {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
    return decoded.userId;
  } catch {
    return null;
  }
}

Functions to sign/verify JWTs with 15min expiry. JWT_SECRET from env var. Add JWT_SECRET=supersecret2026 to .env.local. Pitfall: Never hardcode in production; use Vault or AWS Secrets.

Secure Login Route

src/app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createToken } from '@/lib/auth';
import { users } from '@/lib/db';
import { z } from 'zod';

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const { email, password } = loginSchema.parse(body);
  // Simulate password check (in prod: bcrypt compare)
  const user = Array.from(users.values()).find(u => u.email === email);
  if (!user || password !== 'password123') { // Hardcoded for demo
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }
  const token = createToken(user.ownerId, user.role);
  return NextResponse.json({ token, user: { id: user.id, role: user.role } });
}

Zod validation prevents injections. Simulated password check (use bcrypt in production). Returns JWT. Test: POST {email:'bob@ex.com', password:'password123'} → token. Pitfall: Rate limit login to prevent brute-force.

API3:2023 - Excessive Data Exposure & API4: Lack of Rate Limiting

Excessive Data: APIs return too much (e.g., passwords). Rate Limiting: No quotas → DoS. Solutions: Minimal response schemas + Upstash Redis or in-memory limiter.

User List with Rate Limiting and Minimal Schema

src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getUserIdFromToken } from '@/lib/auth';
import { getUsersByOwner } from '@/lib/db';
import { z } from 'zod';

const requests = new Map<string, number>();
const LIMIT = 10;
const WINDOW = 60 * 1000; // 1min

function rateLimit(ip: string): boolean {
  const now = Date.now();
  const key = ip;
  const count = requests.get(key) || 0;
  if (now - (requests.get(key + '_time') || 0) > WINDOW) {
    requests.set(key, 1);
    requests.set(key + '_time', now);
    return true;
  }
  if (count >= LIMIT) return false;
  requests.set(key, count + 1);
  return true;
}

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') || 'anonymous';
  if (!rateLimit(ip)) {
    return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
  }
  const token = request.headers.get('Authorization')?.replace('Bearer ', '');
  const userId = token ? getUserIdFromToken(token) : null;
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const userList = getUsersByOwner(userId);
  const safeList = userList.map(({ id, name, role }) => ({ id, name, role }));
  return NextResponse.json(safeList);
}

In-memory rate limit by IP (10 req/min). Safe schema excludes email/ownerId. Only owned users. Test: 11 quick requests → 429. Pitfall: In-memory not scalable; use Upstash Redis in production.

API5:2023 - Broken Function Level Authorization

Regular users access admin features (e.g., /admin/users). Verify RBAC (Role-Based Access Control) on every sensitive endpoint.

Admin-Only Route with RBAC

src/app/api/admin/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getUserIdFromToken } from '@/lib/auth';
import { users } from '@/lib/db';

export async function GET(request: NextRequest) {
  const token = request.headers.get('Authorization')?.replace('Bearer ', '');
  const decoded = token ? getUserIdFromToken(token) : null;
  if (!decoded || decoded !== 'alice') { // Simulate admin check via userId or role
    return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
  }
  const allUsers = Array.from(users.values()).map(({ id, email, role }) => ({ id, email, role }));
  return NextResponse.json(allUsers);
}

Checks userId === 'alice' (admin). In production, query role from DB. Pitfall: Centralize RBAC in middleware to avoid duplication.

Best Practices

  • Always validate JWT before DB queries: Prevents leaks on auth errors.
  • Global rate limiting with Upstash Redis or Cloudflare.
  • Zod/Valibot for all inputs/outputs: Stops SQL/NoSQL injections.
  • Strict CORS: next.config.js with allowedOrigins.
  • Structured logs: Winston + Sentry to monitor 403/429.

Common Mistakes to Avoid

  • Forgetting JWT expiry: Infinite sessions → token theft.
  • Sequential IDs: Predictable for BOLA; use UUIDv4.
  • No HTTPS: JWTs sniffed in transit.
  • Rate limit by IP not user: VPN bypass.

Next Steps

Master the full OWASP with our API security training at Learni. Resources: OWASP API Top 10, Next.js Auth.js, OWASP ZAP for automated scans.

How to Secure APIs from OWASP Top 10 in 2026 | Learni