Skip to main content

Protecting API Routes in Next.js: Validation Guide

API routes are the backend of your Next.js application, handling everything from user login to data modifications. Unprotected API routes are a critical vulnerability: attackers can bypass your UI, call endpoints directly, and access or modify data they should not. Protecting API routes means validating that every request is authenticated (the user is logged in) and authorized (the user has permission to perform the action). This validation must happen server-side because clients can be spoofed or modified.

Validating Session Cookies in API Routes

The simplest protection is checking if a user has a valid session. Here is a utility function that extracts and validates the session from a request:

// lib/api-auth.ts
import { NextRequest } from 'next/server';
import { getSession } from './sessions';

export interface SessionUser {
userId: string;
role: string;
}

export async function getSessionUserFromRequest(
req: NextRequest
): Promise<SessionUser | null> {
const sessionId = req.cookies.get('next_session')?.value;

if (!sessionId) {
return null;
}

const session = await getSession(sessionId);
if (!session) {
return null;
}

return {
userId: session.userId,
role: session.role,
};
}

export async function requireSessionUser(req: NextRequest): Promise<SessionUser> {
const user = await getSessionUserFromRequest(req);

if (!user) {
throw new Error('Unauthorized');
}

return user;
}

Now use this in an API route:

// app/api/user/profile/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { requireSessionUser } from '@/lib/api-auth';

export async function GET(req: NextRequest) {
try {
const user = await requireSessionUser(req);

// Fetch the user's profile from the database
const profile = { id: user.userId, email: '[email protected]', role: user.role };

return NextResponse.json(profile);
} catch (error) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}

Any API route that requires authentication calls requireSessionUser(). If the session is invalid or missing, an error is thrown, and the route responds with a 401 status.

Role-Based Authorization in API Routes

Beyond authentication (is the user logged in?), enforce authorization (does the user have permission?). Here is an API route that only admins can access:

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

export async function GET(req: NextRequest) {
try {
const user = await requireSessionUser(req);

// Check if user is an admin
if (user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

// Fetch all users from the database
const users = []; // TODO: Fetch from DB

return NextResponse.json(users);
} catch (error) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}

The response status code matters: 401 means the user is not authenticated (needs to log in); 403 means the user is authenticated but does not have permission (authorization failed). Clients use these codes to handle errors appropriately (401 → redirect to login; 403 → show error message).

Protecting State-Changing Operations (POST, DELETE, PUT)

State-changing operations (POST, PUT, DELETE) must be protected more strictly than reads (GET). Require authentication, authorization, and validate the request body:

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { requireSessionUser } from '@/lib/api-auth';

export async function POST(req: NextRequest) {
try {
const user = await requireSessionUser(req);

// Parse and validate the request body
const body = await req.json();
if (!body.title || !body.content) {
return NextResponse.json(
{ error: 'Missing required fields: title, content' },
{ status: 400 }
);
}

if (body.title.length > 200) {
return NextResponse.json(
{ error: 'Title must be less than 200 characters' },
{ status: 400 }
);
}

// Create the post in the database
const post = {
id: crypto.randomUUID(),
title: body.title,
content: body.content,
authorId: user.userId,
createdAt: new Date(),
};

// TODO: Save to database

return NextResponse.json(post, { status: 201 });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

Notice: validate the request body before using it. Assume clients are malicious and send invalid or oversized data. Validate every field, set size limits, and reject malformed input early.

Protecting Resource Ownership

Users should only modify their own resources unless they have elevated permissions:

// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { requireSessionUser } from '@/lib/api-auth';

// Fetch a post
async function fetchPostFromDB(postId: string) {
// TODO: Query database
return { id: postId, title: 'Example', authorId: 'user-123' };
}

// Delete a post
async function deletePostFromDB(postId: string) {
// TODO: Delete from database
}

export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await requireSessionUser(req);
const post = await fetchPostFromDB(params.id);

if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}

// Check if the user owns the post or is an admin
const isOwner = post.authorId === user.userId;
const isAdmin = user.role === 'admin';

if (!isOwner && !isAdmin) {
return NextResponse.json(
{ error: 'You can only delete your own posts' },
{ status: 403 }
);
}

await deletePostFromDB(params.id);

return NextResponse.json({ success: true, message: 'Post deleted' });
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

This pattern prevents a user from deleting another user's post unless they are an admin. Always fetch the resource from the database and verify ownership before performing the operation.

Rate Limiting and CSRF Protection

Protect against brute force attacks and CSRF with rate limiting and request validation:

// lib/rate-limit.ts
import { RateLimiter } from 'limiter'; // npm install limiter

const limiter = new Map<string, RateLimiter>();

export function getRateLimiter(key: string, maxRequests: number, windowMs: number) {
if (!limiter.has(key)) {
limiter.set(
key,
new RateLimiter({ tokensPerInterval: maxRequests, interval: windowMs })
);
}
return limiter.get(key)!;
}

export async function checkRateLimit(
identifier: string,
maxRequests: number = 100,
windowMs: number = 60000 // 1 minute
): Promise<boolean> {
const rl = getRateLimiter(identifier, maxRequests, windowMs);
return rl.tryRemoveTokens(1) > 0;
}

Use in a login route (should have strict rate limits):

// app/api/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { checkRateLimit } from '@/lib/rate-limit';

export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown';

// Allow 5 login attempts per minute per IP
const allowed = await checkRateLimit(`login:${ip}`, 5, 60000);

if (!allowed) {
return NextResponse.json(
{ error: 'Too many login attempts. Try again in 1 minute.' },
{ status: 429 }
);
}

// ... rest of login logic
}

Key Takeaways

  • Every API route must validate authentication (is the user logged in?) and authorization (do they have permission?).
  • Return 401 for authentication failures and 403 for authorization failures.
  • Validate and sanitize all request body fields; never trust client input.
  • Protect resource ownership: users should only modify their own data unless they are admins.
  • Implement rate limiting on sensitive endpoints (login, password reset) to prevent brute force attacks.
  • Log failed authentication and authorization attempts for security audits.

Frequently Asked Questions

Should I validate the session on every API request?

Yes. Sessions can expire or be revoked between requests. Always validate on every request, even if you cache the user data locally. The database lookup is fast, especially with Redis caching.

Can I use different authentication methods for different routes?

Yes. Some routes might use sessions (your web app) while others use JWTs (your public API). Create separate utilities for each: getSessionUserFromRequest() for sessions, getUserFromJWTRequest() for JWTs. Some routes might support both.

How do I handle CSRF attacks on API routes?

CSRF is primarily a browser vulnerability where a malicious site makes requests on behalf of the user. Mitigation strategies include: HTTP-only cookies (prevent JS-based attacks), SameSite cookie flag (prevent cross-site requests), and CSRF tokens (optional extra layer). Next.js cookies default to SameSite=Lax, providing good CSRF protection.

Should I log all API requests?

Yes, especially failed authentication and authorization attempts. Logs help with security audits and incident investigation. Log the user ID, endpoint, HTTP method, IP address, and result (success/failure reason).

Further Reading