Handling CORS and Rate Limit with Reverse Proxy in NextJS

Introduction

In the previous article, I guided you on using rewrites and proxy in NextJS. Now, we will go into a specific case to set up rate limit directly from the NextJS server to reduce the actual number of requests sent to the core service.

Also in this article, I will guide you on how to configure NextJS as a reversed proxy to avoid CORS errors on the browser effectively.

CORS (Cross-Origin Resource Sharing) is an HTTP-based security mechanism enforced by browsers to prevent websites from sending requests to a domain different from the current website domain (except when the target domain explicitly permits it via response HTTP headers).


When the browser makes an API request to a cross origin target and the server has not configured allowance for that domain, the following error occurs:


Solutions

  • Browser-side handling (exercise caution, not recommended): you can use certain extensions or disable this feature on the browser to bypass it, but the risk is extremely high because this browser feature protects you, preventing personal data from being sent in cross origin requests
  • Using a proxy
    • You can use a third-party intermediary proxy, since it is a server it can forward your request anywhere, however sending data through another server poses a risk of data leakage
    • Deploy a self-hosted reverse proxy, which is the method I will guide you through in this article, it is similar to the proxy solution above but since we deploy it for our own use, it mitigates security risks

The advantages of deploying a self-hosted reverse proxy are as follows:

  • Enhanced security, helping to hide and prevent direct attacks on core services
  • If you use NextJS (as I will guide below), you can create same origin requests
    • Therefore no need to configure CORS for the server
    • No need to spend an extra OPTIONS Preflight request to check access permissions (for cross origin objects), helping to reduce latency because you do not have to call two consecutive requests

Note that if your ecosystem requires deployment on both web and mobile, applying the above method still works, but if the mobile side uses a webview (which inherently behaves like a browser), you must configure CORS allowance for the server.

Detail

First, create a .env file as follows

SERVER_HOST = http://localhost:4000
NEXT_PUBLIC_SERVER_HOST = http://localhost:4000

I set up these two values for server and client use for demo purposes, if you apply rewrites or proxy in production later, you can remove the value with NEXT_PUBLIC because all requests will now go through the NextJS server, helping us hide the actual core server

Next, update the next.config.ts file to add the rewrites configuration

import type {NextConfig} from 'next'

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/v1/:path*',
        destination: process.env.SERVER_HOST + '/v1/:path*',
      },
    ]
  },
}

export default nextConfig

In the rewrites configuration, the system defines routing rules: Only requests starting with /api/v1/ sent from the client will be received and reverse-proxied by the NextJS system to the actual core server address corresponding to the destination configuration.

Create a proxy.ts file, this file will be located at the same level as the app folder in the NextJS project

import {LRUCache} from 'lru-cache'
import type {NextRequest} from 'next/server'
import {NextResponse} from 'next/server'

const tokenCache = new LRUCache<string, number>({
  max: 10000,
  ttl: 60 * 1000,
})

const MAX_REQUESTS_PER_MINUTE = 60

export function proxy(request: NextRequest) {
  const {pathname} = request.nextUrl

  if (pathname.startsWith('/api/v1')) {
    const forwarded = request.headers.get('x-forwarded-for')
    const realIp = request.headers.get('x-real-ip')
    const ip = forwarded
      ? forwarded.split(',')[0].trim()
      : (realIp ?? 'unknown')

    if (ip === 'unknown') {
      return NextResponse.json(
        {message: 'Unable to identify client identity.'},
        {status: 400}
      )
    }

    const currentUsage = tokenCache.get(ip) || 0
    console.log({currentUsage, ip, forwarded, realIp})

    if (currentUsage >= MAX_REQUESTS_PER_MINUTE) {
      return NextResponse.json(
        {
          message: 'Too many requests. Please try again later.',
        },
        {status: 429}
      )
    }

    tokenCache.set(ip, currentUsage + 1)
  }

  return NextResponse.next()
}
  • I use lru-cache to store 10,000 IPs, each existing for 60 seconds and limited to 60 requests/minute
  • You need to pay attention to the following values:
    • x-forwarded-for: if the request passes through multiple proxies or CDNs before reaching this server, this value will be an array of those IPs, at this point you only need to get the first value which originates from the client machine
    • x-real-ip: this is the IP value from the client, however its value can be modified and depends on the proxy configuration, I will guide you on how to get this value appropriately in subsequent articles
  • I'm setting the IP to 'unknown' if we can't retrieve it. This happens with certain network setups or custom proxies where the value isn't passed along. For these cases, you might want to just block them or give them very limited access to keep things secure.

Create an app/security/hook.ts file, these are just simple hooks for demo purposes, note that this hook is used in a client component so it requires the NEXT_PUBLIC_SERVER_HOST value

import {useMutation} from '@tanstack/react-query'

interface ApiResponse {
  message: string
}

interface CreatePayload {
  name: string
}

const requestCreateApi = async (
  baseUrl: string,
  payload: CreatePayload
): Promise<ApiResponse> => {
  const response = await fetch(`${baseUrl}/v1/test/create`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  })

  if (!response.ok) {
    throw new Error(
      `An error occurred while fetching data`
    )
  }
  return response.json()
}

export function useCreateRelativeText() {
  return useMutation({
    mutationFn: (payload: CreatePayload) => requestCreateApi('/api', payload),
  })
}

export function useCreateLocalhostText() {
  return useMutation({
    mutationFn: (payload: CreatePayload) =>
      requestCreateApi(process.env.NEXT_PUBLIC_SERVER_HOST!, payload),
  })
}

Create an app/security/page.tsx file

'use client'

import {MessageOutlined} from '@ant-design/icons'
import {Button, Card} from 'antd'
import {useCreateLocalhostText, useCreateRelativeText} from './hook'

const payload = {
  name: `Test Item`,
}

export default function TestText() {
  const relativeMutation = useCreateRelativeText()
  const localhostMutation = useCreateLocalhostText()
  const data = relativeMutation.data || localhostMutation.data

  return (
    <div className="flex flex-col items-center justify-center min-h-[300px] p-6 bg-gray-50 rounded-xl border border-gray-200 max-w-md mx-auto my-10 shadow-sm">
      <h2 className="text-xl font-semibold text-gray-800 mb-2">Test API</h2>

      <div className="flex gap-4">
        <Button
          type="primary"
          size="large"
          onClick={() => relativeMutation.mutate(payload)}
          className="bg-blue-600 hover:bg-blue-500 font-medium rounded-lg px-6 flex items-center justify-center"
        >
          Same Origin
        </Button>

        <Button
          type="default"
          size="large"
          onClick={() => localhostMutation.mutate(payload)}
          className="border-gray-300 font-medium rounded-lg px-6 flex items-center justify-center"
        >
          Cross Origin
        </Button>
      </div>

      <div className="w-full mt-6">
        {data && (
          <Card
            title={
              <div className="flex items-center gap-2 text-gray-700">
                <MessageOutlined />
                <span>Response Data</span>
              </div>
            }
            className="w-full border border-gray-200 shadow-sm rounded-lg"
            styles={{header: {backgroundColor: '#f9fafb'}}}
          >
            <p className="text-gray-700 font-mono bg-gray-50 p-3 rounded border border-gray-100 break-words text-sm">
              {data.message}
            </p>
          </Card>
        )}
      </div>
    </div>
  )
}


You can compare the results when calling the API directly to a Cross origin target, which will consume an extra OPTIONS Preflight request to check the methods supported by that server before the actual request can be sent


When usage hits the request limit

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Using Kafka with Docker and NodeJS

Sitemap

React Practice Series

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series