Switch Language
Toggle Theme

Next.js API Authentication & Security: A Complete Guide from JWT to Rate Limiting

3 AM. My phone vibrated. A billing alert from my cloud provider—$7,800.

I rubbed my eyes, thinking I misread it. Last month’s bill was $120. I checked the details: 18 million API calls. My personal project normally gets a few hundred calls per day.

Turns out, a crawler found my completely unprotected API endpoint and hammered it for three days straight. That’s when I truly understood why they say API security isn’t optional.

Honestly, many people (including my past self) focus solely on making Next.js pages look good and interactions smooth, thinking API security is a backend concern. But Next.js API Routes essentially are your backend. If you don’t protect them, who will?

In this article, I want to systematically share the lessons I’ve learned and solutions I’ve researched over the years. From JWT authentication to CORS configuration, from rate limiting to input validation—not abstract theories, but practical code you can use directly in your projects.

Why API Security Matters

Common API Security Threats

10.0
CVSS Score

In December last year, React released a critical security advisory, CVE-2025-55182, with a maxed-out CVSS score—10.0.

What does that mean? Perfect score. An attacker could craft a special HTTP request and execute arbitrary code on your server. If you’re using React Server Components without updating, you’re essentially running naked.

This isn’t an isolated case. In March this year, an authorization bypass vulnerability (CVE-2025-29927) scored 9.1. Attackers could forge a request header to bypass your middleware authentication. Thought you were safe with auth? Sorry, they just skip right past it.

Beyond these critical vulnerabilities, daily threats are even more common:

Malicious crawlers and DDoS attacks. APIs without rate limiting get crushed in minutes. I’ve seen login endpoints hit 3,000 times per second for brute-force attacks, crashing servers instantly.

Data leaks. Poor access control lets User A see User B’s order information. This makes the news, and your brand reputation is toast.

Injection attacks. SQL injection, XSS, command injection… sounds old-school, but countless projects still fall victim every year. You think “React auto-escapes for me,” but without API validation, you’re still vulnerable.

Next.js API Routes Characteristics

Next.js API Routes differ from traditional backends in a few important ways:

Serverless-first. Deploying to Vercel means each API request is an independent serverless function. The upside: auto-scaling. The downside: stateless—you can’t use traditional in-memory sessions; you need JWT or database sessions.

Bundled with frontend. Code lives in one repo, and environment variables can easily leak to the client. I’ve seen people put DATABASE_URL in .env, only to have it exposed in the frontend bundle and uploaded to GitHub.

Edge computing limitations. If you use Edge Runtime, some Node.js APIs won’t work—crypto libraries, database connections all need reconsideration. Your security approach must adapt accordingly.

Bottom line: Next.js API is your backend, but it’s lighter, more flexible, and easier to mess up.

Authentication Implementation

Choosing Between JWT and Session

Let’s tackle the most practical question first: JWT or Session, which should you use?

When I started with Next.js, I wrestled with this too. Online opinions are all over the place—some say JWT is the modern must-have, others say Session is more secure. Eventually, I realized it depends on your scenario.

JWT works well when:

  • Your app deploys to multiple servers (serverless, edge nodes)
  • You need cross-domain authentication, like frontend at app.com, API at api.com
  • You don’t want to manage session storage—simpler is better

JWT essentially encodes user info into a token. The server stores no state; clients just include the token with each request. Horizontal scaling becomes trivial.

Session works well when:

  • You need server-side control, like kicking users offline or real-time permission changes
  • Security requirements are extreme—no user info should reside client-side
  • You already have Redis or a database—managing sessions isn’t a problem

Session keeps state server-side; clients only hold a session ID. Want to invalidate a user? Delete the session. JWT can’t do that.

My personal criteria: Small projects and personal projects use JWT for simplicity; enterprise-grade apps needing fine-grained control use sessions. Don’t overthink it—start with one and switch if problems arise.

Complete JWT Implementation

Alright, suppose you decide on JWT. How do you implement it in Next.js?

Step 1: Generate and Verify Tokens

Install a library:

npm install jose

Why not jsonwebtoken? That library doesn’t support Edge Runtime. jose is a Web standard implementation that works anywhere.

Create lib/auth.ts:

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key-at-least-32-characters'
);

export async function createToken(payload: { userId: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m') // Expires in 15 minutes
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch {
    return null;
  }
}

Note that 15m expiration. Many people set 7 days or 30 days for convenience, but if a token leaks, attackers can use it for ages. Short-lived access tokens + long-lived refresh tokens is the proper approach.

Step 2: Store Tokens—Don’t Use localStorage

This is crucial. Many tutorials teach storing tokens in localStorage, then XSS attacks steal everything.

The correct approach: HttpOnly Cookies.

When returning a token from the login endpoint:

// app/api/login/route.ts
import { NextResponse } from 'next/server';
import { createToken } from '@/lib/auth';

export async function POST(request: Request) {
  // Validate username/password...

  const token = await createToken({ userId: user.id });

  const response = NextResponse.json({ success: true });
  response.cookies.set('token', token, {
    httpOnly: true,    // JS can't read it, prevents XSS
    secure: true,      // HTTPS only
    sameSite: 'lax',   // Prevents CSRF
    maxAge: 900,       // 15 minutes, matching token expiration
  });

  return response;
}

HttpOnly is key: JavaScript can’t read this cookie at all. XSS attacks can’t steal it.

Step 3: Protect APIs with Middleware

Now you have tokens. How do you require login for certain APIs? Use Next.js Middleware.

Create middleware.ts:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from './lib/auth';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;

  if (!token) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  const payload = await verifyToken(token);
  if (!payload) {
    return NextResponse.json(
      { error: 'Invalid token' },
      { status: 401 }
    );
  }

  // Verification passed, pass user info to API
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.userId as string);

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

export const config = {
  matcher: '/api/protected/:path*',
};

Now all /api/protected/* endpoints are automatically protected. Retrieve user info in the API:

// app/api/protected/profile/route.ts
import { headers } from 'next/headers';

export async function GET() {
  const headersList = await headers();
  const userId = headersList.get('x-user-id');

  // Fetch user info from database...
}

Step 4: Token Refresh Mechanism

Expires in 15 minutes? Users have to log in constantly? This is where refresh tokens come in.

Access tokens are short-lived (15 minutes), refresh tokens are long-lived (30 days). When the access token expires, use the refresh token to get a new one—no re-login needed.

Implementation is a bit more complex, but that’s the general idea. Many ready-made solutions exist, like NextAuth which has this logic built-in.

Using NextAuth.js for Quick Integration

Honestly, writing all that yourself helps you understand the principles, but it’s a lot of work. If you want to get up and running quickly, just use NextAuth.js (now called Auth.js).

What makes this library great? Out-of-the-box secure defaults:

  • Automatic CSRF protection
  • Session signing and encryption
  • Supports both JWT and database sessions
  • Built-in Google, GitHub, and other third-party logins

Install it:

npm install next-auth

Create app/api/auth/[...nextauth]/route.ts:

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        // Validate username/password...
        if (user) {
          return { id: user.id, email: user.email };
        }
        return null;
      }
    })
  ],
  session: {
    strategy: 'jwt',  // Use JWT, suitable for serverless
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.userId = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.userId = token.userId;
      return session;
    }
  }
});

export { handler as GET, handler as POST };

Then check login status in APIs:

import { getServerSession } from 'next-auth';

export async function GET() {
  const session = await getServerSession();

  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Logged in, continue processing...
}

That simple. NextAuth handles token management and session refresh for you.

CORS Configuration Deep Dive

CORS Essentials and Common Issues

CORS (Cross-Origin Resource Sharing) frustrates many people. Works fine in development, then deployment fails with:

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

Simply put, browsers have a security policy: webpage A can’t freely access website B’s resources. Say you’re on app.com and want to call api.com. The browser asks api.com: “This request is from app.com, do you approve?” The API must explicitly respond “I approve” for the request to proceed.

Why no error in development?

During Next.js development, frontend and API both run on localhost:3000—same origin, no cross-origin issues. In production, frontend might be on Vercel, API elsewhere—now it’s cross-origin.

What’s a preflight request?

Send a POST request with a custom header (like Authorization), and the browser sends an OPTIONS request first to probe. That’s the preflight. If the API doesn’t handle OPTIONS, it returns 404, and CORS fails.

I stepped on this landmine before: wrote a POST endpoint but forgot to handle OPTIONS. Frontend kept throwing CORS errors until I finally figured it out.

Three Ways to Configure CORS in Next.js

Method 1: Global Configuration in next.config.js

Suitable when all APIs allow the same cross-origin sources.

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};

Pros: Configure once, applies globally. Cons: Inflexible, can’t customize per API.

Method 2: Middleware Configuration

Suitable for scenarios needing dynamic decisions and unified handling.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Handle preflight requests
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': 'https://app.example.com',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  // Normal requests, add CORS headers
  const response = NextResponse.next();
  response.headers.set('Access-Control-Allow-Origin', 'https://app.example.com');

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

This approach gets the request object, allowing dynamic decisions based on origin.

Method 3: Per-API Route Configuration

Suitable when individual APIs have special requirements.

// app/api/public/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const data = { message: 'Hello' };

  return NextResponse.json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*', // Public API, allow all origins
    },
  });
}

export async function OPTIONS() {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  });
}

Note: each API needs OPTIONS handling, or preflight will fail.

CORS Security Best Practices

1. Don’t Abuse Wildcards

Seeing many people write:

'Access-Control-Allow-Origin': '*'

This means any website can call your API. Fine for public data, but for user info or sensitive operations, that’s asking for trouble.

Correct approach: explicitly specify allowed domains.

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

const origin = request.headers.get('origin');
if (origin && allowedOrigins.includes(origin)) {
  response.headers.set('Access-Control-Allow-Origin', origin);
}

2. Be Careful with Credentials

If your API needs to read cookies (like session auth), frontend must write:

fetch('https://api.example.com', {
  credentials: 'include',
});

Backend must cooperate:

response.headers.set('Access-Control-Allow-Credentials', 'true');

But, Access-Control-Allow-Origin cannot be *. Browsers directly reject this combination—you must specify an exact domain.

3. Handle Preflight Requests

Remember: requests with Authorization header or Content-Type: application/json almost always trigger preflight. Your API must respond to OPTIONS.

Write a utility function:

export function corsHeaders(origin?: string) {
  return {
    'Access-Control-Allow-Origin': origin || 'https://app.example.com',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // Preflight result cached for 24 hours
  };
}

Then reuse across APIs.

API Rate Limiting

Why Rate Limiting Is Necessary

Back to my opening story. My API got hit 18 million times. Had I implemented rate limiting—say 100 requests per minute per IP—the damage might have been tens of dollars, not $7,800.

Rate limiting restricts how many times a user can call an API within a timeframe. Sounds simple, but it’s powerful:

Prevent DDoS attacks. Attackers want to flood your server? Enable rate limiting, reject requests exceeding the threshold per second, and your server stays rock solid.

Prevent brute-force attacks. No rate limiting on login endpoints? Hackers script 10,000 password attempts per second. With rate limiting, each IP gets 5 attempts per minute—cracking difficulty skyrockets.

Protect resources. Your database and third-party API calls cost money. Rate limiting prevents one user from hogging resources, ensuring fair service for everyone.

Rate Limiting Solutions Comparison

For Next.js rate limiting, mainstream solutions include:

Solution 1: @upstash/ratelimit + Vercel KV

My current go-to. Upstash is a serverless Redis, a Vercel official partner, and integration is super simple.

Pros:

  • Serverless-friendly, no Redis server management
  • Supports multiple algorithms: fixed window, sliding window, token bucket
  • Free tier sufficient for personal projects

Cons:

  • High-traffic projects require payment
  • Depends on third-party service

Solution 2: Self-Hosted Redis

If you already have Redis or don’t want third-party dependencies, implement it yourself.

Pros:

  • Full control, no extra costs
  • Can customize complex logic

Cons:

  • Must maintain Redis server
  • Complex configuration in serverless environments

Solution 3: In-Memory Rate Limiting

Don’t want to install Redis? Pure memory works for simple rate limiting.

Pros:

  • Zero dependencies, few lines of code
  • Suitable for dev environments and small projects

Cons:

  • Serverless environments may spawn new instances per request—memory isn’t shared, rate limiting fails
  • Restarting the server loses all rate limiting data

My recommendation: Personal projects, serverless deployments—go with Upstash; enterprise projects, self-managed servers—use Redis; demos or local dev—memory solution suffices.

Practical Code Example

Using Upstash as an example, here’s a hands-on implementation.

Step 1: Install and Configure

npm install @upstash/ratelimit @upstash/redis

Create a Redis database on Upstash’s website, get UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, add them to .env:

UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token

Step 2: Create Rate Limiter

Create lib/rate-limit.ts:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Create Redis client
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// Create rate limiter: sliding window, max 10 requests per 10 seconds
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
});

slidingWindow(10, '10 s') means: maximum 10 requests per 10 seconds. Sliding window is smoother than fixed window—no boundary spikes.

Step 3: Use in APIs

// app/api/protected/route.ts
import { NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';

export async function GET(request: Request) {
  // Get user IP
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  // Check rate limit
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      {
        error: 'Too many requests',
        limit,
        remaining,
        reset: new Date(reset),
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  // Rate limit passed, process normally
  return NextResponse.json({ data: 'Success' });
}

Here, IP is the rate limit identifier. If you have user login, use userId:

const identifier = session?.userId || ip;
const { success } = await ratelimit.limit(identifier);

This way, logged-in users rate-limited by user, logged-out by IP—more precise.

Step 4: Global Rate Limiting with Middleware

Don’t want to write this in every API? Handle it centrally in middleware:

// middleware.ts
import { ratelimit } from '@/lib/rate-limit';

export async function middleware(request: NextRequest) {
  const ip = request.ip || 'unknown';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

All APIs automatically protected. Easy.

Input Validation and Defense

Why Input Validation Is the First Line of Defense

“Never trust user input”—the golden rule of security.

Your frontend has all sorts of form validation? Useless. Open dev tools, tweak the code, bypass validation entirely. Real defense happens server-side.

SQL injection. User enters '; DROP TABLE users; -- in an input field. If you directly concatenate SQL, your database explodes. Sure, everyone uses ORMs now, but raw SQL scenarios still exist.

XSS attacks. User submits <script>alert('hacked')</script>. You store it in the database. Other users open the page, script executes, cookies stolen. React does auto-escape, but if you use dangerouslySetInnerHTML, you’re still vulnerable.

DoS attacks. User submits a 10MB JSON—your serverless function runs out of memory. Or sends an ultra-long string—regex backtracking until the heat death of the universe.

Input validation blocks most low-level attacks. Without it, any other defenses are Swiss cheese.

Using Zod for Type-Safe Validation

Zod is my current favorite validation library. Written in TypeScript, perfectly integrates with the type system.

Install it:

npm install zod

Basic Usage

Define a schema:

import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().int().min(18).max(120),
});

Validate in API:

// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { userSchema } from '@/lib/schemas';

export async function POST(request: Request) {
  const body = await request.json();

  // Validate data
  const result = userSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: result.error.format(),
      },
      { status: 400 }
    );
  }

  // Validation passed, get type-safe data
  const { email, password, age } = result.data;

  // Continue processing...
}

Note: use safeParse—it doesn’t throw. parse throws, requiring try-catch.

Why better than manual validation?

Manual validation:

if (!body.email || typeof body.email !== 'string') {
  return error;
}
if (!body.email.includes('@')) {
  return error;
}
// Forever and ever...

Zod:

z.string().email()

One line. Done. Types auto-inferred.

Complete Input Validation Approach

Validation isn’t just checking field types—consider business logic and edge cases.

1. Validate Request Body

const postSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().max(10000), // Limit length, prevent oversized input
  tags: z.array(z.string()).max(10), // Limit array length
  publishedAt: z.string().datetime().optional(),
});

2. Validate Query Parameters

// app/api/posts/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);

  const querySchema = z.object({
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
    sort: z.enum(['asc', 'desc']).default('desc'),
  });

  const params = querySchema.parse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
    sort: searchParams.get('sort'),
  });

  // params.page is definitely a number, type-safe
}

z.coerce.number() automatically converts strings to numbers. Super convenient.

3. Custom Validation Rules

const passwordSchema = z.string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase')
  .refine((val) => /[a-z]/.test(val), 'Must contain lowercase')
  .refine((val) => /[0-9]/.test(val), 'Must contain number');

Even async validation:

const emailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  'Email already taken'
);

4. Error Handling

Zod’s error messages are friendly, but you can customize:

if (!result.success) {
  const errors = result.error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
  }));

  return NextResponse.json({ errors }, { status: 400 });
}

Returns structured errors, easy for frontend display.

Other Security Measures

Beyond validation, here are other important security measures:

1. CSRF Protection

Next.js Server Actions have built-in CSRF protection. They compare the Origin and Host headers—mismatch gets rejected.

API Routes need manual handling. If you use NextAuth, it handles this automatically. For manual implementation, use CSRF tokens:

// Generate token, store in cookie, frontend includes it with requests, server compares

2. Content Security Policy (CSP)

Configure CSP in next.config.js to restrict which resources pages can load:

{
  headers: [
    {
      key: 'Content-Security-Policy',
      value: "default-src 'self'; script-src 'self'; style-src 'self';",
    },
  ],
}

Even with an XSS vulnerability, malicious scripts can’t load.

3. Environment Variable Security

Next.js environment variables come in two types:

  • NEXT_PUBLIC_* prefix exposes to frontend
  • Without NEXT_PUBLIC_ stays server-side only

Never put secrets in NEXT_PUBLIC_. I’ve seen people write NEXT_PUBLIC_API_KEY—direct leak.

4. SQL Injection Protection

Using ORMs like Prisma or Drizzle auto-parameterizes queries—basically problem-free.

Must write raw SQL? Parameterize:

// ❌ Dangerous
db.query(`SELECT * FROM users WHERE id = ${userId}`);

// ✅ Safe
db.query('SELECT * FROM users WHERE id = ?', [userId]);

5. Regularly Update Dependencies

Security vulnerabilities often lurk in dependencies. Run regularly:

npm audit
npm update

That React vulnerability in February? Just update to the latest version—fixed. Don’t be lazy—updating saves tons of trouble.

Complete Security Checklist

After all that, here’s a checklist to review your projects:

Authentication Security Checklist

  • ✅ Tokens stored in HttpOnly cookies, not localStorage
  • ✅ Access token expiration ≤ 30 minutes
  • ✅ Refresh token mechanism implemented
  • ✅ JWT secret at least 32 characters, stored in environment variables
  • ✅ Cookie secure and sameSite attributes enabled
  • ✅ Middleware protects sensitive APIs

CORS Configuration Checklist

  • ✅ Don’t use Access-Control-Allow-Origin: * for sensitive APIs
  • ✅ Explicitly specify allowed domain list
  • ✅ Properly handle OPTIONS preflight requests
  • ✅ When credentials needed, set Access-Control-Allow-Credentials: true
  • ✅ Verify CORS config works in production

Rate Limiting Checklist

  • ✅ Critical endpoints (login, registration, password reset) have strict rate limiting
  • ✅ Differentiate rate limits for authenticated vs unauthenticated users
  • ✅ Return 429 status and Retry-After header
  • ✅ Use persistent storage like Redis or Upstash to avoid serverless failures
  • ✅ Monitor rate limit triggers, adjust thresholds as needed

Input Validation Checklist

  • ✅ All user input validated server-side
  • ✅ Use Zod or similar tools for type-safe validation
  • ✅ Limit max length for strings, arrays, objects
  • ✅ Validate data formats (email, URL, date, etc.)
  • ✅ Return clear validation error messages

Regular Audit Checklist

  • ✅ Run npm audit at least monthly, fix critical vulnerabilities
  • ✅ Update Next.js and React to latest stable versions promptly
  • ✅ Subscribe to Next.js security advisories, watch for new vulnerabilities
  • ✅ Review environment variables, ensure no secrets leaked to frontend
  • ✅ During code reviews, focus on authentication and permission logic

Print this checklist and stick it on your desk. Review before starting new projects, and again before deployment.

Conclusion

After all this, the core message is simple: API security is a system-wide effort, not a one-time fix.

You might find it tedious—authentication, CORS, rate limiting, validation, each requiring configuration. But wait until problems arise—data leaks, servers crushed, sky-high bills—then trying to fix it becomes far more costly.

My experience: configuring this from scratch takes half a day to a full day. But once set up, it’s basically “copy-paste and tweak parameters” for new projects—done in 10-15 minutes. Most importantly, you can sleep soundly at night without worrying about waking up to an attack.

Action Items:

  1. Immediately audit existing projects, check against the list for gaps
  2. Start with critical items, add auth and rate limiting first, refine the rest later
  3. Subscribe to security news, follow Next.js official blog and GitHub Security Advisories
  4. Share with your team—security is everyone’s responsibility, not just yours

One last thing: that December 2025 CVSS 10.0 React vulnerability had massive impact. If you haven’t updated yet, upgrade to the latest version immediately. Security updates truly can’t be delayed.

The API security journey is long, but every step is worthwhile. I hope this article helps you avoid some pitfalls and get your security defenses in place sooner.

FAQ

Should Next.js API use JWT or Session authentication?
Depends on your scenario. Serverless deployments and cross-domain auth suit JWT—stateless and scalable. Scenarios needing server-side control (kicking users offline) or extreme security suit sessions. Personal projects: JWT recommended. Enterprise apps: sessions recommended.
Why can't JWT tokens be stored in localStorage?
Because localStorage is accessible to JavaScript—successful XSS attacks can steal tokens. Use HttpOnly cookies instead. JavaScript can't read them at all, preventing token theft even if XSS vulnerabilities exist.
Development doesn't throw CORS errors, why does production?
In development, frontend and API both run on localhost:3000—same origin, no cross-origin issues. In production, frontend and API might be on different domains, triggering browser CORS checks. Solution: properly configure Access-Control-Allow-Origin headers on the API side.
How to implement rate limiting in serverless environments?
Recommended: @upstash/ratelimit + Vercel KV solution. Serverless functions may be new instances each request—memory isn't shared, pure in-memory rate limiting fails. Upstash Redis provides persistent storage shared across all instances, perfectly fitting serverless.
How to prevent API brute-force password attacks?
Implement multi-layer protection: 1) Strict rate limiting on login endpoints (e.g., max 5 attempts per IP per minute); 2) Validate password strength with Zod; 3) Implement account lockout (lock for 30 minutes after 5 consecutive failures); 4) Add CAPTCHA to increase cracking cost.

15 min read · Published on: Jan 5, 2026 · Modified on: Jan 22, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts