DEV Community

Favour Okpara
Favour Okpara

Posted on

Implementing Secure Authentication in Next.js with External APIs: A BFF Pattern Approach

TL;DR

Next.js middleware can't read httpOnly cookies from external APIs because they're cross origin. The solution: create Next.js API routes that proxy requests to your external backend, setting same origin cookies that middleware can access. This BFF (Backend for Frontend) pattern is secure but adds maintenance overhead. Use it when working with external APIs you don't control and security is critical.

A few weeks back, I was working on a project to build a website for a tutor in Next.js that required me to integrate login and register APIs for user authentication. Coming from a React background, it seemed easy and straightforward. All I had to do was ensure that my backend was sending the authorization token as cookies, then include it in my API calls by adding credentials—and that was that.

The React Authentication Pattern

Example of login in React below

const login = async (e) => {
  e.preventDefault();
  setError("");
  setLoading(true);

  try {
    const response = await fetch("https://external-api.com/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      const data = await response.json();
      throw new Error(data.error || "Login failed");
    }

    const data = await response.json();

    router.push("/dashboard");
  } catch (err) {
    setError(err.message);
  } finally {
    setLoading(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

This works well in a traditional React SPA, but how do I prevent unauthenticated or unauthorized users from accessing protected routes in Next.js?

The Problem with Client-Side Authentication

The normal React way of protecting routes is to wrap protected routes in an AuthProvider connected to a global state management solution (Zustand, Redux, etc.) and conditionally render based on whether the user is authenticated or not. This is UI based authentication, and while it works, several critical issues concerned me:

  1. No real security - A user can manipulate the client side state (localStorage, Redux store, etc.) to bypass the protection
  2. API calls still need protection - Even if you hide a route, someone can still make API requests directly
  3. Token exposure - If you store tokens in localStorage, they're accessible to JavaScript and vulnerable to XSS attacks

The Next.js Middleware Challenge

I was building in Next.js and I knew that Middleware (running at the edge) could be paired with my client-side API calls to provide better security. So I got to work, asking my middleware to check if the user is authorized by verifying cookies. But it was then I realized the core problem:

I can't access httpOnly cookies set by my external backend domain in my Next.js middleware because they're cross origin.

The middleware runs on my Next.js domain (e.g., myapp.com), but the cookies are set by my external API (e.g., external-api.com). Due to browser security policies, these cookies simply aren't available to my middleware. So the user would always appear unauthenticated because middleware doesn't have access to the particular httpOnly cookie I was looking for.

Why Common Workarounds Don't Work

I considered asking my backend developer to include the accessToken in the response body for a successful login, then set the cookie using document.cookie.

Example code below

const login = async (e) => {
  e.preventDefault();
  setError("");
  setLoading(true);

  try {
    const response = await fetch("https://external-api.com/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      const data = await response.json();
      throw new Error(data.error || "Login failed");
    }

    const data = await response.json();

    // Store token in regular cookie (NOT httpOnly)
    document.cookie = `accessToken=${data.accessToken}; path=/; max-age=${24 * 60 * 60}; SameSite=Strict; Secure`;

    router.push("/dashboard");
  } catch (err) {
    setError(err.message);
  } finally {
    setLoading(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

This approach defeats the entire purpose of what I was trying to achieve because:

  1. Token accessible to JavaScript (XSS risk) - Any malicious script can read document.cookie and steal the token
  2. Cross Origin complications - When making authenticated requests to my external backend, I'd need to manually attach this token, potentially causing CORS issues

So I couldn't do without my middleware because I didn't want just UI based authentication, but when I involved my middleware, I was unnecessarily putting my entire project at risk by exposing the accessToken to JavaScript.

The Solution: Backend for Frontend (BFF) Pattern with Next.js API Routes

After research, I decided to implement what's essentially a Backend for Frontend (BFF) pattern using Next.js server routes. This approach bypasses the cross origin cookie problem entirely by creating a proxy layer between my client and the external API.

The flow is straightforward:

  1. Client queries my Next.js API route (same domain)
  2. Next.js API route queries the external backend
  3. Next.js receives the token from the external API
  4. Next.js sets httpOnly cookies on its own domain
  5. Middleware can now read these same origin cookies
  6. All subsequent requests go through the Next.js proxy

Step 1: Create the Login API Route

Under src/app, I created an api/login/route.ts file to act as a proxy to my external backend:

// app/api/login/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    // Parse request body
    const body = await request.json();
    const { email, password } = body;

    // Validate input
    if (!email || !password) {
      return NextResponse.json(
        { error: 'Email and password are required' },
        { status: 400 }
      );
    }

    // Call external backend
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email, password }),
    });

    // Handle backend errors
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      return NextResponse.json(
        { error: errorData.message || 'Login failed' },
        { status: response.status }
      );
    }

    // Get data from backend
    const data = await response.json();
    // Expected: { accessToken: "...", user: { id, email, name, ... } }

    // Create response with user data (NO TOKEN in response body)
    const res = NextResponse.json(
      { 
        user: data.user,
        message: 'Login successful' 
      },
      { status: 200 }
    );

    // Set httpOnly cookie with accessToken
    res.cookies.set('accessToken', data.accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 24 * 60 * 60, // 24 hours
      path: '/',
    });

    return res;
  } catch (error) {
    console.error('Login error:', error);
    return NextResponse.json(
      { error: 'An unexpected error occurred' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Client-Side Login Component

Let the client query the login server route:

// app/login/page.tsx

'use client';

import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleLogin = async (e: FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'include', // Important: allows cookies to be set
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || 'Login failed');
      }

      const data = await response.json();
      console.log('Login successful:', data.message);

      // Redirect to dashboard
      router.push('/dashboard');
      router.refresh(); // Refresh to update server components
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="flex min-h-screen items-center justify-center">
      <form onSubmit={handleLogin} className="w-full max-w-md space-y-4 p-8">
        <h1 className="text-2xl font-bold">Login</h1>

        {error && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
            {error}
          </div>
        )}

        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-2">
            Email
          </label>
          <input
            id="email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            className="w-full px-3 py-2 border rounded-lg"
            placeholder="you@example.com"
          />
        </div>

        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-2">
            Password
          </label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            className="w-full px-3 py-2 border rounded-lg"
            placeholder="••••••••"
          />
        </div>

        <button
          type="submit"
          disabled={loading}
          className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
        >
          {loading ? 'Logging in...' : 'Login'}
        </button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Middleware for Route Protection

Finally, create middleware to handle initial route protection. Because we've set cookies in our login server route (on the same domain), middleware has access to them and JavaScript never sees the actual token.

//middleware.ts (same level as app folder)

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

export function middleware(request: NextRequest) {
  // Check for authentication token
  const accessToken = request.cookies.get('accessToken');

  // If no auth cookie, redirect to login
  if (!accessToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Token exists, allow request to proceed
  // NOTE: This doesn't validate if token is valid/expired
  // That validation happens in your page components/layouts
  return NextResponse.next();
}

// Specify which routes to protect
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/profile/:path*',
    '/settings/:path*',
    // Add more protected routes here
  ],
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Server-Side Token Validation

Middleware can only check if the cookie exists, not if it's valid or expired. You must validate the token on the server side in your protected pages or layouts.

// app/dashboard/layout.tsx

import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

async function validateToken(token: string) {
  try {
    // Call your backend to verify the token
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/verify`, {
      headers: {
        'Authorization': `Bearer ${token}`,
      },
    });

    if (!response.ok) {
      return null;
    }

    return await response.json();
  } catch (error) {
    return null;
  }
}

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const cookieStore = cookies();
  const accessToken = cookieStore.get('accessToken')?.value;

  if (!accessToken) {
    redirect('/login');
  }

  // CRITICAL: Validate the token with your backend
  const user = await validateToken(accessToken);

  if (!user) {
    // Token is invalid or expired
    redirect('/login');
  }

  return (
    <div>
      {/* Your dashboard layout */}
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What This Approach Achieves

With the steps above, I've created a secure authentication flow that addresses all my original concerns:

1. Maximum Security

  • HttpOnly cookies - Token can't be accessed by client side JavaScript (XSS protection)
  • Token never exposed - Client never sees the actual token in response body
  • Server-side validation - Backend validates every request through the proxy
  • No localStorage/sessionStorage - Eliminates common XSS attack vectors
  • Same-origin cookies - Middleware can enforce route protection before page loads

2. Better User Experience

  • Middleware protection - Unauthorized users are redirected BEFORE the page loads (no flash of protected content)
  • Seamless authentication - No loading states for auth checks on protected routes
  • Clean separation - Authentication logic is centralized in API routes

3. Leverages Next.js Native Features

  • Built-in middleware - No third party auth libraries needed for basic protection
  • Server components - Can read cookies on the server for SSR
  • Route protection - Declarative protection via middleware matcher
  • API routes - Natural place to handle authentication logic

4. Bypasses Cross Origin Issues

This approach doesn't solve CORS, it bypasses it entirely. By creating a same-origin proxy, you eliminate the cross-origin cookie problem. Your Next.js app becomes the single domain your frontend interacts with, while the external API remains completely separate.

The Trade-offs You Need to Consider

After discussing with colleagues and implementing this at scale, I have to acknowledge this approach comes with significant trade-offs:

1. Increased Complexity

  • More code to maintain - You need API routes for login, logout, refresh, and potentially every protected endpoint
  • Duplicate error handling - Must handle errors in both API routes and external backend
  • Cookie synchronization - Must keep Next.js cookies in sync with backend authentication state
  • Proxy maintenance - Every new backend endpoint needs a corresponding API route

2. Critical Middleware Limitations

This is the most important caveat: Middleware can only check if a cookie exists, not if it's valid or expired. This means:

  • Expired tokens will pass middleware checks
  • Invalid tokens will pass middleware checks
  • Revoked sessions will pass middleware checks

The actual validation still happens later either in your API routes when you proxy requests to the backend, or in server components when you validate the token. This creates a false sense of security if you're not aware of it.

Middleware provides a first line of defense (preventing completely unauthenticated access and avoiding UI flashes), but server-side validation in your components/layouts is mandatory for true security.

3. Performance Considerations

  • Extra network hop - Every request goes through your Next.js proxy before reaching the backend
  • Increased latency - Added overhead compared to direct backend calls
  • Server load - Your Next.js server now handles authentication proxy traffic

4. Scalability Concerns

  • Session management complexity - Token refresh, logout, and session invalidation all need proxy routes
  • State synchronization - Keeping authentication state consistent across your Next.js layer and backend
  • Deployment considerations - Your Next.js app now needs to be highly available since it's a critical proxy layer

When to Use This Pattern

This BFF (Backend-for-Frontend) pattern is appropriate when:

  • You're integrating with an external API you don't control that uses httpOnly cookies
  • You need server-side route protection with middleware
  • Security is a high priority and you want to eliminate XSS token exposure
  • You're willing to accept the maintenance overhead of a proxy layer
  • Your team has the resources to maintain parallel API routes

When NOT to Use This Pattern

Consider alternatives if:

  • You control both frontend and backend (use a monorepo or unified auth)
  • Your project is small/simple
  • You can use NextAuth.js or similar libraries (they handle this complexity for you)
  • Performance is critical and you can't afford the extra network hop
  • Your backend supports alternative auth methods (like token-based auth with short-lived tokens)

Conclusion

Implementing secure authentication in Next.js when working with external APIs requires understanding the limitations of cross origin cookies and the middleware execution model. The BFF pattern I've described solves real security problems but introduces architectural complexity.

The key insight is this: middleware provides route-level access control, but you still need server side token validation in your components. Think of middleware as a bouncer checking if you have a ticket, while your server components are the venue staff verifying the ticket is actually valid.

This approach has worked well for my tutor website project, but I wouldn't recommend it for every Next.js application. Evaluate your specific requirements, team capacity, and security needs before implementing this pattern. Sometimes a simpler solution with different trade-offs is the right choice, but for applications requiring this level of security with external APIs, it's a worthwhile investment.

Top comments (0)