Guides18 min read

How to Stop Leaking API Keys: A Practical Guide for Frontend and Full-Stack Developers

Keep private API keys out of browser bundles, protect your backend proxy from abuse, scan for exposed credentials, and respond quickly when a key leaks.

P
PayloadAPI Engineering TeamEngineering & Architecture
How to Stop Leaking API Keys: A Practical Guide for Frontend and Full-Stack Developers
Contents

An API key can leave your application in more ways than a committed.envfile. It can appear in a browser bundle, a debug log, a screenshot, or a response your server sends to the client.

Moving the key to your backend is a useful first step. But if anyone can call that backend without limits, they may still be able to spend your API budget without ever seeing the credential.

Effective protection combines three things: keep secrets on the server, control who can use them, and make exposure easier to detect and contain.

#Know which keys must stay private

A provider's secret API key, an admin credential, and a database password belong on infrastructure you control. They should never be bundled into a website or distributed inside a mobile application as a shared secret.

Some products deliberately issue public or publishable keys for client applications. These are a different category:

Credential

Appropriate place

Required protection

Private provider API key

Backend or server-side secret store

Narrow permissions, controlled access, rotation

Supabase publishable or legacyanonkey

Browser, where the integration requires it

Correct Row Level Security policies and user authentication where needed

Firebase web configuration API key

Browser, for the intended Firebase services

Firebase Security Rules and appropriate service restrictions; App Check where supported

Supabase secret orservice_rolekey

Backend only

Never expose it to the client; it can bypass RLS

Supabase and Firebase use different authorization mechanisms; Firebase Security Rules are not Supabase RLS. Follow each provider's documented client integration. A public key does not make the underlying data public by default, but incorrect access rules can. See theSupabase key guideandFirebase API-key guidance.

#Treat public environment variables as public

Putting a credential in an environment variable does not automatically keep it secret. Its destination matters.

In Next.js, variables prefixed withNEXT_PUBLIC_can be included in browser JavaScript. In Vite,VITE_variables are exposed to client code by default. These mechanisms are intended for public configuration. See theNext.js environment-variable guideandVite documentation.

tsx
// Unsafe when this value is a private provider credential.
const apiKey = process.env.NEXT_PUBLIC_WEATHER_SECRET;

fetch('https://weather-provider.example/v1/current', {
  headers: { Authorization: `Bearer ${apiKey}` },
});

The browser must receive that value to send the request. Minification, a hidden UI, or an obfuscated variable name cannot make it private.

Use a server-side variable such asWEATHER_API_SECRETinstead. Keep secret-reading modules out of client imports, and never serialize their values into props, HTML, JSON responses, or logs. In Next.js, animport 'server-only'guard can help catch accidental client imports.Next.js data-security guidance

#Protect the proxy as well as the key

A common pattern is:

Browser → your authenticated backend → API provider

The browser sends the user's request. Your backend verifies access, checks usage limits, attaches the private credential, and returns only the fields the interface needs.

The browser sends a request to your backend, which verifies the user, checks limits, and attaches the secret before calling the provider. Only a filtered response returns to the browser.
The browser sends a request to your backend, which verifies the user, checks limits, and attaches the secret before calling the provider. Only a filtered response returns to the browser.

*The provider secret belongs on the backend. The browser receives the result of an authorized request.*

Next.js Route Handlers are public HTTP endpoints unless you protect them. A route called/api/weatheris still reachable by scripts outside your frontend. CORS does not replace authentication or stop a non-browser client from making requests.Next.js backend-for-frontend guide,OWASP REST security guidance

Before forwarding a paid request, your backend should:

  • Verify the caller and their permission to use the feature.

  • Enforce request limits and an appropriate usage budget on the server.

  • Validate inputs and keep the upstream destination under server control.

  • Apply a timeout, handle provider errors, and return a limited response.

For an intentionally anonymous feature, choose explicit anonymous quotas and abuse controls. Hiding its URL is not a substitute.

#A Next.js Route Handler pattern

The example below shows the request boundary, not a complete deployable application. It assumes Node.js runtime support and an installedserver-onlypackage.

The two imported helpers areapplication-specific integration points, not built-in Next.js APIs:

  • authorizeWeatherRequest(request)verifies the session, permission, and the application's cross-site request policy for billable calls. It returns{ ok: true, userId }or{ ok: false, status: 401 | 403 }.

  • consumeWeatherAllowance(userId)atomically checks and consumes a request allowance using a shared store. It returns a boolean, covers per-user and service-wide limits, and throws if the limiter is unavailable. This example counts admitted attempts, including ones where the provider later fails.

Implement those helpers before deploying. A counter in one process will not coordinate limits across multiple server instances.

typescript
// app/api/weather/route.ts
import 'server-only';
import { NextResponse, type NextRequest } from 'next/server';
import {
  authorizeWeatherRequest,
  consumeWeatherAllowance,
} from '@/lib/server/weather-access'; // Implement in your application.

export const runtime = 'nodejs';

const json = (body: unknown, status = 200) =>
  NextResponse.json(body, {
    status,
    headers: { 'Cache-Control': 'no-store' },
  });

export async function GET(request: NextRequest) {
  try {
    const access = await authorizeWeatherRequest(request);
    if (!access.ok) return json({ error: 'Access denied' }, access.status);

    const city = request.nextUrl.searchParams.get('city')?.trim();
    if (!city || city.length > 80) {
      return json({ error: 'Provide a city of 1–80 characters' }, 400);
    }

    const secret = process.env.WEATHER_API_SECRET;
    if (!secret) return json({ error: 'Service unavailable' }, 503);

    if (!(await consumeWeatherAllowance(access.userId))) {
      return json({ error: 'Request allowance exceeded' }, 429);
    }

    // Replace this reserved example host with your chosen provider.
    // Never accept the upstream URL or Authorization header from the user.
    const url = new URL('https://weather-provider.example/v1/current');
    url.searchParams.set('city', city);

    const upstream = await fetch(url, {
      headers: { Authorization: `Bearer ${secret}`, Accept: 'application/json' },
      signal: AbortSignal.timeout(5_000),
      redirect: 'error',
      cache: 'no-store',
    });

    if (!upstream.ok) return json({ error: 'Provider request failed' }, 502);

    // Illustrative schema: adapt this validation to your provider's response.
    const data = await upstream.json();
    const temperatureC = data?.current?.temp_c;
    if (typeof temperatureC !== 'number' || !Number.isFinite(temperatureC)) {
      return json({ error: 'Invalid provider response' }, 502);
    }

    return json({ city, temperatureC });
  } catch {
    // Fail closed; do not return raw exception details or request headers.
    return json({ error: 'Weather service temporarily unavailable' }, 503);
  }
}

Your frontend can now call/api/weather?city=Londonusing its normal authenticated session. It never needs the provider credential.

Before shipping, also bound response sizes, configure safe operational logging, and test denied users, cross-site calls, exhausted allowances, timeouts, and malformed upstream responses. Cookie-authenticated billable requests need protection against cross-site triggering too, even when they use GET: enforce your request-origin policy in the authorization helper or use a POST flow with validated CSRF protection. Add caching only after deciding which results can safely be shared across users; the example deliberately disables it.

#Keep secrets out of Git and build artifacts

Start with an ignore rule that covers local environment-file variants and permits clearly named examples:

gitignore
.env*
!.env.example
!.env.*.example

Keep example values empty or obviously nonfunctional:

dotenv
WEATHER_API_SECRET=
DATABASE_URL=

An ignore rule does not untrack a file already committed, erase history, or exclude it from a container image. Review your build context and artifact packaging separately. Use your deployment platform's secret store for production credentials.

Deleting a leaked key from the latest commit does not invalidate it. History rewrites also cannot recall every clone, fork, cached view, or copy someone already saved. Revoke the credential before treating repository cleanup as complete.GitHub's sensitive-data removal guide

#Add secret scanning before and after commits

Use scanning as another opportunity to catch mistakes. With Gitleaks installed, scan staged changes with the currentgitcommand:

bash
# macOS installation, if needed
brew install gitleaks

# Check staged changes and redact detected values in output.
gitleaks git --pre-commit --staged --redact .

For a project using Husky, add that scan command as its own line in.husky/pre-commit, preserving existing checks. Theofficial Gitleaks hookuses these staged-scan flags.

Run scanning in CI too. Local hooks can be bypassed, and pattern-based scanners can miss credentials. For a history scan, ensure CI has fetched the history you intend to inspect:

bash
gitleaks git --redact .

Where available, enable your repository host's secret scanning and push protection. Also review logs, source maps, generated assets, and screenshots: repository scanning only covers part of the exposure surface.Gitleaks documentation

#Where PayloadAPI fits

For configured marketplace integrations, PayloadAPI's gateway attaches the upstream provider credential server-side. Your backend authenticates with aPayloadAPI application keyfor its test or live environment.

That application key is still a secret. Keep it on your backend and protect the endpoint that uses it. Avoid distributing one shared production key across unrelated applications.

PayloadAPI also checks quotas, configured usage caps, and available prepaid funds for supported commercial plans. PAYG caps apply to request spending; monthly-plan overage caps apply to additional usage beyond the included allowance. They do not replace your own authentication, cap every subscription charge, or undo damage caused before a limit is reached.

If a key is exposed, rotate or revoke the affected application key and update the services that use it. Gateway changes propagate through route snapshots, so do not assume immediate global invalidation. Rotating a PayloadAPI application key does not rotate an upstream provider's underlying credential.

#If a private key has already leaked

  1. Revoke the exposed credential promptly.Stop ongoing misuse rather than waiting for Git cleanup. If continuity requires a replacement first, keep the overlap as short as possible and confirm the old key is disabled.

  2. Replace it everywhere it is used.Update server secrets, deployment settings, and affected services. Remove the original exposure path.

  3. Investigate the whole exposure window.Review access logs, usage, billing, and permission changes from the earliest plausible exposure through revocation. Do not limit the check to the last two hours.

  4. Clean up remaining copies.Address repositories, artifacts, logs, shared documents, and caches as appropriate. Coordinate history rewrites with collaborators; they are not a substitute for revocation.

  5. Contact the provider when needed.Share the incident timeline and affected resources through its support process. Billing adjustments depend on the provider and circumstances; refunds are not guaranteed.

GitHub'sleaked-secret remediation guidemakes revocation a central step. After recovery, add a check for the path that allowed the exposure so the same mistake is easier to catch next time.

Your browser should receive the result of an authorized API request, not the credential that pays for it. Keep that boundary clear, enforce access and usage limits, and plan for rotation before an incident forces the issue.

Explore the [PayloadAPI catalog](https://payloadapi.com/apis) and read [how monthly plans and the shared usage wallet work](https://payloadapi.com/blog/how-payloadapi-hybrid-billing-works).