Secure API Routes in Next.js with Middleware and JWT

by Didin J. on Oct 23, 2025 Secure API Routes in Next.js with Middleware and JWT

Learn how to secure API routes in Next.js using Middleware and JWT authentication. Protect sensitive endpoints with token validation and best practices.

In today’s modern web applications, securing your API routes is no longer optional — it’s essential. Whether you’re building a public-facing app or an internal dashboard, your backend endpoints often handle sensitive data such as user information, tokens, or payment details. Without proper protection, these routes can easily become a target for unauthorized access and data breaches.

With Next.js, especially from version 13 onward, securing API routes has become more streamlined thanks to middleware — a built-in feature that allows you to run code before a request is completed. Middleware makes it easy to intercept requests, validate authentication tokens, and redirect users if they’re not authorized — all at the edge, before the API route logic executes.

One of the most effective ways to handle authentication in modern web applications is through JSON Web Tokens (JWT). JWT provides a stateless and compact way to verify user identity between the client and server. Combined with Next.js middleware, JWT can protect both API routes and pages, ensuring only authorized users can access certain resources.

In this tutorial, you’ll learn step-by-step how to:

  • Create secure API routes in Next.js

  • Implement JWT-based authentication

  • Use middleware to protect routes and verify tokens efficiently

  • Test your protected endpoints using real examples

By the end, you’ll have a working Next.js project with secure, production-ready API routes that demonstrate best practices for modern web app authentication.



Prerequisites

Before diving into this tutorial, make sure your development environment is ready and that you’re familiar with a few essential concepts. Here’s what you’ll need:

Technical Requirements

  1. Node.js (v18 or later)
    Next.js requires at least Node.js 18 to support the latest features, including Middleware and Edge API routes.
    Check your version with:

     
    node -v

     

  2. npm or yarn
    You’ll use either npm or yarn to install dependencies. npm comes bundled with Node.js.

  3. A code editor
    Visual Studio Code is recommended for its integration with JavaScript frameworks and linting tools.

  4. Basic web development knowledge
    You should understand JavaScript/TypeScript, HTTP requests, and REST API concepts.

💡 Familiarity With:

  • Next.js basics — knowing how pages, components, and API routes work.

  • Authentication fundamentals — especially how JWT tokens are used to authenticate requests.

  • Environment variables — since we’ll use .env.local to securely store secrets like JWT keys.

📦 Dependencies We’ll Use

Throughout this tutorial, you’ll install and use the following npm packages:

Package Purpose
next The main Next.js framework
react / react-dom Required peer dependencies for Next.js
jsonwebtoken To generate and verify JWT tokens
bcryptjs To hash passwords (optional, for login example)
dotenv To manage environment variables (if not already supported by Next.js)

🧰 What You’ll Build

By the end of this tutorial, you’ll have a fully functional Next.js project that includes:

  • A login endpoint that generates JWTs

  • A middleware that protects API routes

  • A secure profile API route that validates tokens before returning data



Setting Up a New Next.js Project

Let’s start by creating a brand-new Next.js project that will serve as the foundation for our secure API routes. We’ll be using the latest Next.js 15 (or the most recent stable version) with the App Router enabled by default.

🧱 Step 1: Create a New Next.js App

Run the following command in your terminal:

npx create-next-app@latest nextjs-secure-api

When prompted, you can select the following options:

✔ Would you like to use TypeScript? › Yes
✔ Would you like to use ESLint? › Yes
✔ Would you like to use Tailwind CSS? › No
✔ Would you like to use src/ directory? › Yes
✔ Would you like to use App Router? › Yes
✔ Would you like to customize the default import alias (@/*)? › No

This creates a new project folder named nextjs-secure-api with TypeScript and the App Router structure.

Next, navigate into your project directory:

cd nextjs-secure-api

Start the development server to verify everything works:

npm run dev

Then open http://localhost:3000 in your browser — you should see the default Next.js welcome page.

Secure API Routes in Next.js with Middleware and JWT - npm run dev

🔧 Step 2: Install Required Dependencies

We’ll need a few additional packages for JWT authentication and password hashing:

npm install jsonwebtoken bcryptjs
npm i --save-dev @types/jsonwebtoken

Optionally, you can install dotenv if you want to manage environment variables manually (though Next.js already supports .env files):

npm install dotenv

🗝️ Step 3: Set Up Environment Variables

Next.js automatically reads environment variables from .env.local.
Create a new file in the project root:

touch .env.local

Then add the following content:

JWT_SECRET=your_super_secret_jwt_key
JWT_EXPIRES_IN=1h

🧠 Tip: Use a long, complex random string for JWT_SECRET in production, and never commit this file to version control.

📁 Step 4: Verify Folder Structure

After setup, your project structure should look something like this:

nextjs-secure-api/
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   └── route.ts
│   │   ├── layout.tsx
│   │   └── page.tsx
│   └── middleware.ts        ← We'll create this later
├── .env.local
├── package.json
└── tsconfig.json

🚀 Step 5: Test the Server

Finally, restart the dev server to ensure everything runs smoothly:

npm run dev

Your base setup is now complete! You have a clean Next.js project ready to implement authentication and secure API routes.



Creating a Simple Authentication API

Now that your Next.js project is ready, let’s create a basic authentication API route that generates JWT tokens when users successfully log in.
We’ll simulate a user login using a hardcoded user object for simplicity — in real-world scenarios, you’d connect this to a database.

🧩 Step 1: Create the Login API Route

Inside your project, create a new file at:

src/app/api/auth/login/route.ts

Add the following code:

import { NextResponse } from "next/server";
import jwt, { SignOptions } from "jsonwebtoken";
import bcrypt from "bcryptjs";

const user = {
  id: 1,
  username: "admin",
  password:
    "$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36x.4Y2YlW3K9fIx0D9eH2a", // 'password123' hashed
};

export async function POST(request: Request) {
  try {
    const { username, password } = await request.json();

    // Validate username
    if (username !== user.username) {
      return NextResponse.json({ message: "Invalid username" }, { status: 401 });
    }

    // Compare password
    const valid = await bcrypt.compare(password, user.password);
    if (!valid) {
      return NextResponse.json({ message: "Invalid password" }, { status: 401 });
    }

    // Ensure JWT secret exists
    const secret = process.env.JWT_SECRET;
    if (!secret) {
      throw new Error("JWT_SECRET is not defined in environment variables");
    }

    // Resolve expiresIn: if it's a pure number string, convert to number.
    const rawExpires = process.env.JWT_EXPIRES_IN ?? "1h";
    let expiresInValue: number | string;

    // if the env var is like "3600" (only digits), pass number; otherwise pass string like "1h"
    if (/^\d+$/.test(rawExpires)) {
      expiresInValue = Number(rawExpires);
    } else {
      expiresInValue = rawExpires;
    }

    // Cast through unknown to match internal StringValue union that isn't exported.
    const options: SignOptions = {
      expiresIn: expiresInValue as unknown as SignOptions["expiresIn"],
    };

    const payload = { id: user.id, username: user.username };
    const token = jwt.sign(payload, secret, options);

    return NextResponse.json({ token }, { status: 200 });
  } catch (error) {
    console.error("Login error:", error);
    return NextResponse.json({ message: "Internal server error" }, { status: 500 });
  }
}

🧠 Explanation

  • The route listens for POST requests to /api/auth/login.

  • It checks if the username and password match our dummy user.

  • If valid, it signs a JWT token using the secret from .env.local.

  • The token contains basic user info (id and username) and expires after 1 hour.

🧪 Step 2: Test the Login API

Start the development server:

npm run dev

Then open your terminal or Postman, and send a POST request to:

POST http://localhost:3000/api/auth/login

With the JSON body:

{
  "username": "admin",
  "password": "password123"
}

If everything works, you should receive a response like:

{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc2MTE4MzcwMCwiZXhwIjoxNzYxMTg3MzAwfQ.NMWyb25WZv7YAlRqOfasqssh8_kGWOxyjcHKsKBa3pQ"
}

If you provide the wrong credentials, you’ll get an appropriate 401 Unauthorized error.

⚙️ Step 3: (Optional) Hash a New Password

If you want to create your own password hash, run this snippet in a Node.js REPL or script:

node

Then inside the Node shell:

const bcrypt = require('bcryptjs');
bcrypt.hash('password123', 10).then(console.log);

Copy the output and replace the hashed password in the user object.

Your login endpoint is now working and ready to issue JWT tokens.



Implementing Middleware for Route Protection

Now that your login route can generate a valid JWT, the next step is to protect your API routes so that only authenticated users can access them.
Next.js makes this easy with Middleware, a special function that runs before a request is processed by your route handler.

🧭 What Middleware Does

Middleware in Next.js intercepts incoming requests and allows you to:

  • Check if a user has a valid JWT token.

  • Redirect unauthorized users.

  • Run authentication logic globally, without repeating it in every route.

We’ll use it to verify JWT tokens for specific API routes (like /api/user/profile).

🧩 Step 1: Create the Middleware File

In your project, create a new file at the root of your src directory:

src/middleware.ts

Then add the following code:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import jwt from "jsonwebtoken";

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Allow access to login route without a token
  if (pathname.startsWith("/api/auth/login")) {
    return NextResponse.next();
  }

  // Get token from Authorization header or cookies
  const authHeader = req.headers.get("authorization");
  const token = authHeader?.split(" ")[1];

  if (!token) {
    return NextResponse.json({ message: "Missing token" }, { status: 401 });
  }

  try {
    // Verify the token
    jwt.verify(token, process.env.JWT_SECRET as string);
    return NextResponse.next();
  } catch (err) {
    return NextResponse.json({ message: "Invalid or expired token" }, { status: 403 });
  }
}

⚙️ Step 2: Define Which Routes Are Protected

Next.js Middleware runs on every request by default.
To limit it to only certain paths (like /api/user routes), add the following export to the bottom of the same file:

export const config = {
  matcher: ["/api/user/:path*"],
};

This ensures the middleware only runs for routes starting with /api/user/.


🧠 How It Works

  • The middleware checks each request that matches the matcher paths.

  • If the path is /api/auth/login, it skips token verification (so users can still log in).

  • Otherwise, it looks for an Authorization: Bearer <token> header.

  • The token is verified using your secret key — if it’s invalid or expired, access is denied.

🧪 Step 3: Test Middleware with an Invalid Request

Start your development server again:

npm run dev

Then send a request to a protected route (which we’ll create next) without a token:

GET http://localhost:3000/api/user/profile

You should receive:

{
  "message": "Missing token"
}

Now, try including a valid token from your earlier /api/auth/login response:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...

Once the middleware confirms the token, the request will proceed to the next handler.

Your JWT verification middleware is now active and ready to protect sensitive routes.



Creating a Protected API Route

Now that you have middleware verifying JWT tokens, let’s create a protected API route that only authenticated users can access.
This route will return user profile data, but only if the request includes a valid JWT token.

🧩 Step 1: Create the Protected Route

Inside your project, create a new file:

src/app/api/user/profile/route.ts

Then add the following code:

import { NextResponse } from "next/server";
import jwt from "jsonwebtoken";

export async function GET(request: Request) {
  try {
    // Extract the Authorization header
    const authHeader = request.headers.get("authorization");
    const token = authHeader?.split(" ")[1];

    if (!token) {
      return NextResponse.json({ message: "Token is missing" }, { status: 401 });
    }

    // Verify and decode the token
    const decoded = jwt.verify(token, process.env.JWT_SECRET as string);

    // Example: return some user data
    return NextResponse.json({
      message: "Access granted to protected route!",
      user: decoded,
    });
  } catch (error) {
    console.error("Error verifying token:", error);
    return NextResponse.json({ message: "Invalid or expired token" }, { status: 403 });
  }
}

🧠 Explanation

  • The route listens for GET requests to /api/user/profile.

  • It retrieves the token from the Authorization header.

  • It uses jwt.verify() to decode and validate the token.

  • If valid, it returns a success message and user information (decoded from the token payload).

🧩 Note: Since the middleware already checks for valid tokens, this route will only be reached if the token passes verification. However, verifying again inside the handler ensures safety and flexibility (e.g., if you add role-based access later).

🧪 Step 2: Test the Protected Endpoint

Run the server if it’s not already running:

npm run dev

Then open Postman or curl and send a request:

GET http://localhost:3000/api/user/profile

Add this header (replace the token with your actual one):

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...

If your token is valid, you’ll get a response like this:

{
  "message": "Access granted to protected route!",
  "user": {
    "id": 1,
    "username": "admin",
    "iat": 1715000000,
    "exp": 1715003600
  }
}

If the token is missing or invalid, you’ll see:

{
  "message": "Invalid or expired token"
}

🔒 Step 3: Confirm Middleware Protection

Try removing the middleware configuration (matcher) temporarily or comment it out, and you’ll see that it /api/user/profile becomes accessible without a token, confirming that the middleware is actively enforcing route protection.

You now have a fully functional, secure API route in Next.js that’s protected by JWT authentication and verified using Middleware.



Testing Authentication and Protected Routes

Now that you’ve built both the authentication API and middleware-protected routes, it’s time to test everything end-to-end. You can use Postman, Insomnia, or the built-in Next.js API routes from your browser to verify that everything works as intended.

Step 1: Start the Next.js Development Server

Run the development server:

npm run dev

By default, your app will be available at:

http://localhost:3000

Step 2: Test the Login API

In Postman (or your preferred API client):

  • Method: POST

  • URL: http://localhost:3000/api/auth/login

  • Headers:
    Content-Type: application/json

  • Body (raw JSON):

{
  "username": "admin",
  "password": "password123"
}

Expected response:

{
  "message": "Login successful",
  "token": "<your-jwt-token>"
}

If you get this response, your JWT token generation and authentication logic are working properly.

Step 3: Access the Protected Route Without a Token

Now, test the protected API route without including the JWT.

  • Method: GET

  • URL: http://localhost:3000/api/protected

Expected response:

{
  "error": "Unauthorized"
}

This confirms that your middleware correctly blocks unauthenticated requests.

Step 4: Access the Protected Route With a Token

Now, take the token returned from the /api/auth/login response and use it in the Authorization header.

  • Method: GET

  • URL: http://localhost:3000/api/protected

  • Headers:

    Authorization: Bearer <your-jwt-token>

Expected response:

{
  "message": "This is a protected route",
  "user": {
    "id": 1,
    "username": "admin"
  }
}

This shows your JWT middleware correctly validates the token and passes the user payload to the route.

Step 5: Test Token Expiry (Optional)

If you set JWT_EXPIRES_IN to something short like "10s", try waiting for 10 seconds and then accessing the protected route again.
You should receive an error:

{
  "error": "Invalid or expired token"
}

This confirms token expiration is enforced correctly.

At this point, you’ve successfully:

  • Implemented JWT authentication,

  • Protected your API routes using Next.js middleware,

  • And verified the entire flow works securely.



Best Practices and Security Tips

Now that you’ve implemented JWT authentication and protected API routes in your Next.js app, it’s important to ensure your setup remains secure, maintainable, and production-ready. Here are some essential best practices and security tips to follow:

🛡️ 1. Never Hardcode Secrets

Always store your JWT secret and other sensitive configuration values in environment variables (.env.local), not directly in your codebase.

✅ Do:

JWT_SECRET=supersecuresecretkey123

❌ Don’t:

const secret = "supersecuresecretkey123";

If your code is in version control (e.g., GitHub), never commit your .env file.

🔒 2. Use a Strong, Random JWT Secret

Your JWT secret should be a long, random string — at least 32 characters — and generated using a secure method.
For example:

openssl rand -base64 64

This makes brute-forcing the token virtually impossible.

🧭 3. Set Token Expiration

Always define a reasonable token lifespan using expiresIn (e.g., "1h" or "15m").
This limits how long a stolen token can be used.

For even better security, consider implementing refresh tokens with shorter-lived access tokens.

🧹 4. Validate and Sanitize Inputs

Even though this example uses hardcoded credentials, in real-world apps, always validate user input to prevent injection attacks.

Use libraries like:

  • zod

  • yup

  • Or built-in schema validation for frameworks like Next.js API routes.

🚪 5. Limit Token Scope

Keep your JWT payload minimal — only include essential user data such as id or role.
Avoid embedding sensitive data like passwords, emails, or API keys.

Example of a minimal payload:

{
  "id": 1,
  "role": "admin"
}

🧠 6. Handle Token Errors Gracefully

Your middleware should always respond with clear but secure error messages — don’t reveal unnecessary details about why the token failed.

✅ Good:

{ "error": "Unauthorized" }

❌ Bad:

{ "error": "JWT malformed at position 37" }

🌐 7. Use HTTPS in Production

JWT tokens are sent in headers — so always use HTTPS in production environments to prevent interception via man-in-the-middle attacks.

If you’re deploying on Vercel, HTTPS is automatically handled for you.

🧩 8. Consider Storing JWTs in HTTP-only Cookies

While this tutorial uses headers, in production-grade apps, you can store JWTs in HTTP-only cookies for better protection against XSS attacks.
However, be aware of CSRF risks, and mitigate them using anti-CSRF tokens or SameSite=strict cookies.

🔁 9. Refresh Token Mechanism

For longer sessions, implement a refresh token system:

  • Short-lived access token (e.g., 15 min)

  • Long-lived refresh token (e.g., 7 days)

  • When the access token expires, use the refresh token to obtain a new one

This reduces the damage if an access token is compromised.

🧰 10. Keep Dependencies Updated

Regularly update dependencies like jsonwebtoken and Next.js.
Security patches often address critical vulnerabilities that attackers can exploit.

Following these best practices ensures your API routes remain secure, scalable, and maintainable — whether you’re building an internal API or a full production-grade SaaS.



Conclusion and Next Steps

In this tutorial, you learned how to secure API routes in Next.js using middleware and JWT (JSON Web Tokens) — one of the most common and reliable patterns for protecting modern web applications.

Here’s a quick recap of what you achieved:

  • ✅ Created a new Next.js 15 project and set up TypeScript.

  • ✅ Built a simple authentication API that issues JWTs on successful login.

  • ✅ Implemented Next.js Middleware to verify tokens and guard protected routes.

  • ✅ Tested the authentication flow end-to-end using Postman.

  • ✅ Learned essential security best practices for handling JWTs safely.

With these foundations in place, your app can now distinguish between public and private routes, securely validate users, and protect sensitive endpoints — all within the built-in Next.js routing system.

🚀 Next Steps

If you want to expand this project further, here are some great directions to explore:

  1. Connect to a Real Database
    Replace the hardcoded user with a real user table using Prisma, MongoDB, or PostgreSQL.

  2. Add User Registration and Logout Endpoints
    Allow users to sign up and log out by invalidating or rotating tokens.

  3. Implement Refresh Tokens
    Use short-lived access tokens and long-lived refresh tokens to enhance security.

  4. Protect Frontend Pages
    Use middleware or React hooks (e.g., useEffect with token checks) to protect Next.js client pages as well as API routes.

  5. Deploy Securely
    Deploy your app to Vercel or another cloud provider with environment variables and HTTPS configured.

💡 Final Thoughts

By leveraging Next.js Middleware with JWT authentication, you gain fine-grained control over access to your API routes — all while keeping your application fast and serverless-friendly.

This approach scales seamlessly whether you’re building a small internal dashboard or a full-scale SaaS product.

You can find the full source code on our GitHub.

That's just the basics. If you need more deep learning about Next.js, you can take the following cheap course:

Thanks!