Data Security in Next.js: Hiding Keys & API Proxying

Learn how to secure Next.js apps using the BFF pattern, validate environment variables, hide backend URLs, and prevent critical web attacks.

Vladyslav Tsyvinda
Vladyslav Tsyvinda
·Updated
Data Security in Next.js: Hiding Keys & API Proxying

Data Security in Next.js: Hiding Keys, Sensitive Data, and API Proxying

Security in modern web applications is not just about adding HTTPS or configuring basic CORS rules. When we build complex systems with a separated architecture, where the frontend (e.g., an admin panel on Next.js) and the backend service (an API written in Go, Node.js, or Python) exist as separate entities, the issue of hiding the internal infrastructure becomes critical.

If you allow the user’s browser to communicate directly with the backend, you’re laying your infrastructure’s cards on the table for attackers. Next.js, thanks to its hybrid nature (Server-Side and Client-Side), allows you to implement the BFF (Backend-for-Frontend) architectural pattern. In this model, Next.js acts as an intermediary layer (Middleman) and the single point of entry for the client, reliably protecting internal services.


1. Attack vectors: what does infrastructure hiding protect against?

When request logic, secret keys, and actual API endpoints remain on the client side, the application becomes vulnerable to a range of serious threats. Proxying through Next.js helps neutralize the following attack vectors:

1.1. Token Leaks and Credential Stuffing

If you use third-party services (payment gateways, email marketing services) or internal microservices, they require API keys. A request from the browser means that the key will inevitably end up in the client-side JavaScript bundle. An attacker can easily extract it via the Network tab in DevTools or by simply searching through JS files to later use your paid quotas or access credentials.

1.2. Direct DDoS on the Backend (Direct-to-Origin Attacks)

If the client application makes requests directly to https://api.my-backend.com, your server’s address becomes public. Even if the main site is protected by Cloudflare, an attacker can send thousands of heavy requests (for example, for complex filtering or report generation) directly to the backend IP, bypassing the protective filters, which will cause the server to crash.

1.3. Parameter Tampering and Data Scraping

When a browser communicates with the API directly, an attacker can see the structure of the responses. They can attempt to alter parameters in the request body (for example, substituting role=admin instead of role=user) or mass-download confidential data (scraping), since they see the “raw” JSON containing database service fields.

1.4. Bypassing and Compromising CORS

To allow the browser to make requests to an API on a different domain or port, developers often relax the CORS configuration (for example, by allowing Access-Control-Allow-Origin: *). This opens the door to Cross-Site Request Forgery (CSRF) attacks. Proxying solves this: the client accesses the same domain where Next.js is hosted, so the CORS issue doesn’t exist in the first place.


2. Basic security hygiene and environment variable validation

Next.js has a clear boundary between the server-side and client-side environments. However, a mistake in the prefix can compromise the security of the entire application.

Server-side vs. Client-side variables

By default, all variables in the .env.local file are accessible exclusively in the Node.js environment (on the server).

# This key will NEVER reach the browser
DATABASE_URL="postgresql://user:pass@localhost:5432/db"
STRIPE_SECRET_KEY="sk_test_12345"
INTERNAL_BACKEND_URL="http://backend-service:8080"

# This variable WILL be included in client-side JavaScript via the prefix
NEXT_PUBLIC_ANALYTICS_ID="G-12345678"

The golden rule: Never add the NEXT_PUBLIC_ prefix to authorization tokens, database keys, or internal microservice URLs.

Strict validation using Zod

To prevent the application from crashing at runtime due to a forgotten variable in a Docker container or CI/CD pipeline, validate .env during the project build phase using the zod library.

// src/env.ts
import { z } from 'zod';

const serverSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  INTERNAL_BACKEND_URL: z.string().url(),
  SERVER_TO_SERVER_SECRET: z.string().min(10),
});

const _serverEnv = serverSchema.safeParse(process.env);

if (!_serverEnv.success) {
  console.error('❌ Invalid environment variables:', _serverEnv.error.format());
  throw new Error('Environment variable validation failed');
}

export const env = _serverEnv.data;

Now, in your server-side code, you import env from the src/env.ts file, ensuring full typing and confidence that all critical data is present.


3. Security at the architectural level: Server Components and Server Actions

With the introduction of the App Router (app/), components have become Server Components by default. Their code runs exclusively on the server, and the client receives only the generated HTML code.

If you make a request to a database or a third-party API directly from a Server Component, the browser sees neither the request logic nor any secret tokens:

// app/dashboard/page.tsx (Server Component)
import { env } from '@/src/env';

export default async function DashboardPage() {
  // The request is executed on the Node.js server during page rendering
  const res = await fetch(`${env.INTERNAL_BACKEND_URL}/v1/metrics`, {
    headers: {
      'Authorization': `Bearer ${process.env.SECRET_API_TOKEN}`
    }
  });

  const data = await res.json();

  return (
    <main>
      <h1>Metrics Dashboard</h1>
      {/* The client receives clean HTML, the API structure is hidden */}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </main>
  );
}

The same approach works for data mutations using Server Actions ("use server"). All business logic, access control, and key management remain behind the server’s closed doors.


4. Proxy requests and mask backend URLs

When the client-side of the application (e.g., an interactive paginated table or a dynamic form) needs to send requests on its own, we mask the actual backend. There are two ways to do this.

Option A: Next.js Rewrites (Simple Redirect)

If you only need to change the endpoint name to hide the actual backend host, use rewrites in the config.

// next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: '/api/proxy/:path*',
        destination: 'https://real-backend-service.com/api/:path*',
      },
    ]
  },
}

How it works: The browser makes a fetch('/api/proxy/users') request. Next.js intercepts this request and proxies it to real-backend-service.com at the server level. The client sees only the URL of your own site.

Option B: Route Handlers / API Routes (Maximum Security)

Rewrites won’t work if you need to add a server-side token, check the user’s session, or filter data before sending a request to the backend. In that case, Route Handlers are used.

1. Server-side proxy layer with secret injection and data sanitization:

// app/api/internal/users/route.ts
import { NextResponse } from 'next/server';
import { env } from '@/src/env';

export async function POST(request: Request){
  try {
    // Receive data from the client
    const body = await request.json();

    // You can add validation via Zod here before sending to the backend

    // Form a request to the isolated backend
    const response = await fetch(`${env.INTERNAL_BACKEND_URL}/v1/users`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        // Inject a secret key that is not and cannot be on the client
        'X-Internal-Secret': env.SERVER_TO_SERVER_SECRET,
        'X-Forwarded-For': request.headers.get('x-forwarded-for') || 'unknown',
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      console.error(`Backend error: ${response.status}`);
      return NextResponse.json(
        { error: 'Failed to create user. Please contact support.' },
        { status: response.status }
      );
    }

    const data = await response.json();

    // DATA SANITIZATION
    // Remove sensitive fields of the internal DB before sending to the browser
    delete data.internal_database_id;
    delete data.password_hash_algorithm;
    delete data.internal_flags;

    return NextResponse.json(data);

  } catch (error) { 
    console.error('Proxy Error:', error);
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
  }
}

2. Secure client-side component:

'use client'

export function CreateUserForm() {
  const handleCreate = async (formData) => {
    // The client only knows about the local relative path
    const res = await fetch('/api/internal/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData),
    });

    const result = await res.json();
    if (res.ok) alert('User created successfully!');
  };

  // ... form rendering
}

5. Infrastructure synergy: Docker, Nginx, and Cloudflare

The BFF architectural pattern works 100% only when supported by a properly configured deployment server infrastructure (e.g., DigitalOcean Droplet or a private cloud).

Network-level isolation (Docker)

The Next.js and backend containers must be located within a single isolated virtual network (Docker bridge network). In the docker-compose.yml file for the backend container, the ports block is not specified at all (there is no mapping like 8080:8080). The backend’s ports do not expose to the outside world. The Next.js container communicates with it using the service’s internal DNS name: http://backend-api:8080. It is physically impossible to reach the backend from the outside.

Nginx as a Reverse Proxy

Nginx is installed in front of the Next.js application, which handles SSL certificate termination and initial protection:

  • Limiting the maximum size of uploaded files (client_max_body_size 10M).
  • Filtering of suspicious or empty User-Agent headers.
  • Redirecting all HTTP traffic to HTTPS.

Configuring Edge Protection in Cloudflare

Since all client requests (including data operations) now pass through a single Next.js domain, you can utilize Cloudflare WAF tools to their full potential:

  • Rate Limiting: Set up a rule that will block an IP address if it makes more than 10 requests per second to endpoints /api/internal/*. This will protect your proxy routes from brute-force attacks and scraping.
  • Cache Rules: If your Route Handlers return static or infrequently updated information (e.g., reference guides, product catalogs), you can allow Cloudflare to cache the responses from these API endpoints. Subsequent user requests will be served instantly from Cloudflare’s edge servers, without loading either Next.js or your main backend at all.

Conclusion

Implementing the BFF pattern with Next.js creates a robust protective shield around your business logic and databases. By combining strict environment control via Zod, server code isolation in Server Components, flexible proxying via Route Handlers, and a closed infrastructure at the Docker and Cloudflare levels, you implement the concept of Defense in Depth, making your application resilient to most modern cyber threats.