NIYONSHUTI Emmanuel
HomeMusing

© 2024 - 2026 NIYONSHUTI Emmanuel. All rights reserved.

source code
All posts
Web Development

Handling HttpOnly Cookies Between Next.js and a Backend API on Different Domains

When your Next.js frontend and backend API are deployed on different domains, middleware can't read httpOnly cookies. Here's why it happens and two practical solutions to fix it.

NIYONSHUTI Emmanuel

November 15, 2025

Note: This post documents an issue I ran into while deploying a Next.js frontend and an Express backend on separate domains, and how I approached solving it. I'm primarily a backend developer, so this write-up focuses strictly on the cookie/middleware problem rather than the broader Next.js ecosystem.

Cookies

Prerequisites

You should have basic familiarity with React or Next.js and making API requests.

This isn't a full authentication tutorial—it focuses only on why httpOnly cookies fail across domains and the exact setup that fixed it for me.

This guide assumes you're using Next.js 12.2+ (middleware support) or the App Router in Next.js 15. The examples use the App Router.

Note: I'm using a separate Express backend and a Next.js 15 frontend.


The Problem

Next.js has a special middleware.js file that runs before a request reaches your pages or API routes. It executes on the edge runtime, meaning it can intercept requests, check cookies, or redirect users before any rendering happens. This can be useful for protecting routes because you can check authentication tokens before rendering anything.

However, middleware can only read cookies from the same domain.

Example:

  1. Your frontend runs on mysite.com
  2. Your backend runs on example.com
  3. User logs in → backend sets an httpOnly cookie on example.com
  4. Browser stores that cookie tied to example.com
  5. When middleware runs on mysite.com, it can't see cookies from example.com

Browsers isolate cookies per domain for security. The middleware on mysite.com can only read cookies set by mysite.com.


The Setup

Let's say you have this login route on the backend at https://example.com:

app.post("/api/login", async (req, res, next) => {
  try {
    const { email, password } = req.body;
    
    const user = await prisma.user.findUnique({ 
      where: { email },
      select: { id: true, email: true, password: true }
    });
    
    if (!user || !await bcrypt.compare(password, user.password)) {
      return next(new HttpError(401, 'Invalid email or password'));
    }
    
    const token = jwt.sign(
      { id: user.id },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    );
    
    res.cookie('token', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
      path: '/'
    });
    
    res.status(200).json({
      success: true,
      message: 'Login successful',
      data: { user: { id: user.id, email: user.email } }
    });
  } catch (error) {
    next(new HttpError(500, error.message || 'login failed'));
  }
});

The frontend request:

const handleSubmit = async (e) => {
  e.preventDefault();
  
  const response = await fetch('https://example.com/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
    credentials: 'include' // Important: allows cookies to be sent/received
  });

  if (response.ok) {
    router.push('/private');
    router.refresh();
  }
};

Even after a successful login, your Next.js middleware might still redirect users to /login because the cookie is tied to example.com, not your frontend domain.

middleware.js for token validation:

import { NextResponse } from 'next/server';

const API_BASE_URL = 'https://example.com';

export async function middleware(request) {
  const path = request.nextUrl.pathname;
  
  if (path === '/private/login') {
    return NextResponse.next();
  }
  
  const token = request.cookies.get('token')?.value;
  
  if (!token) {
    return NextResponse.redirect(new URL('/private/login', request.url));
  }
  
  try {
    const response = await fetch(`${API_BASE_URL}/api/verify`, {
      method: 'POST',
      headers: {
        'Cookie': `token=${token}`,
        'Content-Type': 'application/json',
      },
    });
    
    if (!response.ok) {
      return NextResponse.redirect(new URL('/private/login', request.url));
    }
  } catch (error) {
    return NextResponse.redirect(new URL('/private/login', request.url));
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ['/private/:path*'],
};

Backend verification route:

app.post("/api/verify", async (req, res, next) => {
  try {
    const token = req.cookies?.token;
    
    if (!token) {
      return res.status(403).json({ success: false, authenticated: false });
    }
    
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
    if (!decoded) {
      return res.status(403).json({ success: false, authenticated: false });
    }
    
    const user = await prisma.user.findUnique({
      where: { id: decoded.id },
      select: { id: true, email: true }
    });
    
    if (!user) {
      return next(new HttpError(401, 'Invalid token - user not found'));
    }
    
    return res.status(200).json({ success: true, message: 'valid token' });
  } catch (error) {
    next(new HttpError(500, 'Token verification failed'));
  }
});

With this setup, your middleware will keep redirecting to the login page even after successful login. Because request.cookies.get('token') returns undefined because the cookie was set by example.com but middleware is running on mysite.com.


Two Possible Solutions

Approach 1: Shared Root Domain with Cookie Domain Attribute

If your frontend and backend are on subdomains of the same root domain (e.g., api.example.com and site.example.com), you can make cookies accessible across both by setting the domain attribute.

When setting the cookie in Express:

res.cookie('token', token, {
  httpOnly: true,
  domain: ".example.com", // Makes cookie available to all subdomains
  secure: process.env.NODE_ENV === 'production',
  sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
  maxAge: 7 * 24 * 60 * 60 * 1000,
  path: '/'
});

By setting domain: ".example.com", the cookie becomes accessible to both api.example.com and site.example.com. The leading dot makes it available to all subdomains.

When to use this: You control both domains and can deploy them as subdomains of the same root domain.


Approach 2: Next.js API Route as Proxy

If your frontend and backend are on completely different domains (e.g., mysite.com and example.com), you can use a Next.js API route as a proxy.

The flow:

  1. Frontend calls /api/login (Next.js API route—same domain)
  2. Next.js API route forwards request to Express backend
  3. Backend validates credentials and returns JWT
  4. Next.js API route sets the cookie (on the frontend domain)
  5. Middleware can now read it (same domain)

Important: For this approach, modify your backend to return the token in the response body:

// Backend: Add token to response
res.status(200).json({
  success: true,
  message: 'Login successful',
  token: token, // Add this
  data: { user: { id: user.id, email: user.email } }
});

Next.js API route (app/api/login/route.js):

import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

const API_BASE_URL = 'https://example.com';

export async function POST(request) {
  try {
    const body = await request.json();
    
    const response = await fetch(`${API_BASE_URL}/api/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    
    const data = await response.json();
    
    if (response.ok && data.token) {
      // Set cookie on frontend domain
      const cookieStore = await cookies();
      cookieStore.set('token', data.token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'lax',
        maxAge: 7 * 24 * 60 * 60 * 1000,
        path: '/'
      });
      
      return NextResponse.json({
        success: true,
        message: data.message,
        data: data.data
      });
    }
    
    return NextResponse.json(data, { status: response.status });
  } catch (error) {
    return NextResponse.json(
      { error: 'Login failed' },
      { status: 500 }
    );
  }
}

Update your login form to call the Next.js API route:

const handleSubmit = async (e) => {
  e.preventDefault();
  
  const response = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (response.ok) {
    router.push('/private');
    router.refresh();
  }
};

Note on performance: This middleware approach makes a network call to your backend on every protected request. For better performance, consider verifying JWTs directly in middleware using a library for example you can check jose library (which works in the Edge runtime), eliminating the need for backend calls on each request.


Key Takeaway

When working with httpOnly cookies across domains, remember: middleware can only read cookies from the same domain. Either use subdomains with shared cookie domains (Approach 1), or proxy through Next.js API routes to keep cookies on the frontend domain (Approach 2).


Which Approach Should You Choose?

Use Approach 1 (Shared Domain) if:

  • You control DNS for both services
  • Can deploy as subdomains (api.example.com, app.example.com)
  • Want simpler setup with better performance

Use Approach 2 (Proxy) if:

  • Domains are completely different
  • You can't modify DNS/domain structure
  • Backend is third-party or you can't change deployment setup

Enjoyed this post? Share it.

Share on XLinkedIn