Zum Inhalt springen

Schutz von API-Schlüsseln und Secrets in Frontend-Anwendungen

Schütze API-Schlüssel im Frontend mit Backend-Proxys, Token-Verwahrung, sauberen Umgebungsvariablen und Secret-Injection zur Laufzeit.

4 Min. Lesezeit
Ein Sicherheitsdiagramm, das zeigt, wie API-Schlüssel über einen Backend-Proxy geleitet werden, anstatt in die JavaScript-Bundles des Frontends eingebettet zu sein

Jedes Frontend-Secret ist öffentlich

Alles, was an den Browser ausgeliefert wird, ist lesbar. Minifizierung, Verschleierung und Präfixe für Umgebungsvariablen schützen keine Secrets. Sobald ein Wert im Client-Bundle landet, kann ihn ein Angreifer einfach über die DevTools auslesen. Wer das als feste Rahmenbedingung akzeptiert, gestaltet den Zugriff auf APIs von Grund auf anders.

Das Backend-Proxy-Muster

Der zuverlässigste Ansatz: Das Secret niemals an den Client senden. Leite alle Aufrufe von Drittanbieter-APIs über dein eigenes Backend, das das Secret serverseitig verwahrt.

tstypescript
// ❌ API key in the frontend — visible in network tab and bundle
const response = await fetch(
  `https://api.maps.example.com/geocode?key=sk_live_abc123&address=${address}`
);
 
// ✅ Backend proxy — secret stays on the server
// Frontend calls your API
const response = await fetch(`/api/geocode?address=${encodeURIComponent(address)}`);
tstypescript
// Backend proxy route (Next.js API route example)
import { NextRequest, NextResponse } from "next/server";
 
export async function GET(req: NextRequest) {
  const address = req.nextUrl.searchParams.get("address");
 
  if (!address || address.length > 200) {
    return NextResponse.json(
      { error: "Invalid address parameter" },
      { status: 400 }
    );
  }
 
  // Secret never leaves the server
  const apiKey = process.env.MAPS_API_KEY;
 
  const result = await fetch(
    `https://api.maps.example.com/geocode?key=${apiKey}&address=${encodeURIComponent(address)}`,
  );
 
  if (!result.ok) {
    return NextResponse.json(
      { error: "Geocoding service unavailable" },
      { status: 502 }
    );
  }
 
  const data = await result.json();
  return NextResponse.json(data);
}

Sauberer Umgang mit Umgebungsvariablen

Framework-Konventionen wie NEXT_PUBLIC_ oder VITE_ kennzeichnen Variablen, die ins Client-Bundle eingebunden werden. Wer das missversteht, lässt Secrets direkt ins Produktions-Bundle durchsickern.

tstypescript
// ❌ Secret with public prefix — bundled into client JavaScript
// .env
// NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123
 
// ✅ Only publishable keys get the public prefix
// .env
// NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz789  (safe for client)
// STRIPE_SECRET_KEY=sk_live_abc123                   (server only)
 
// Validation at startup — catch misconfigured secrets early
function validateEnvironment(): void {
  const publicVars = Object.keys(process.env).filter((key) =>
    key.startsWith("NEXT_PUBLIC_")
  );
 
  const sensitivePatterns = [
    /SECRET/i,
    /PRIVATE/i,
    /SK_LIVE/i,
    /PASSWORD/i,
    /TOKEN/i,
  ];
 
  for (const varName of publicVars) {
    for (const pattern of sensitivePatterns) {
      if (pattern.test(varName)) {
        throw new Error(
          `Potentially sensitive variable "${varName}" has NEXT_PUBLIC_ prefix. ` +
          `This will be exposed in the client bundle. ` +
          `Remove the NEXT_PUBLIC_ prefix if this is a secret.`
        );
      }
    }
  }
}

Token-Scoping und -Rotation

Wenn sich ein Client direkt gegenüber einer API authentifizieren muss, verwende eng begrenzte, kurzlebige Tokens statt langlebiger API-Schlüssel.

tstypescript
// ❌ Long-lived API key with full permissions
// const apiKey = "sk_live_full_access_forever";
 
// ✅ Short-lived, scoped token generated server-side
interface ScopedToken {
  token: string;
  expiresAt: number;
  permissions: string[];
  resourceRestrictions: Record<string, string>;
}
 
// Backend endpoint that generates scoped tokens for the client
export async function POST(req: NextRequest) {
  const session = await getSession(req);
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  const scopedToken = await tokenService.create({
    userId: session.userId,
    permissions: ["read:own-files", "write:own-files"],
    resourceRestrictions: {
      bucket: `user-${session.userId}`,
    },
    expiresInSeconds: 900, // 15 minutes
  });
 
  return NextResponse.json({
    token: scopedToken.token,
    expiresAt: scopedToken.expiresAt,
  });
}
 
// Client uses the scoped token for direct uploads
async function uploadFile(file: File): Promise<string> {
  // Get a fresh scoped token from our backend
  const { token, expiresAt } = await fetch("/api/upload-token", {
    method: "POST",
  }).then((r) => r.json());
 
  if (Date.now() > expiresAt) {
    throw new Error("Token expired before upload could start");
  }
 
  // Use scoped token to upload directly to storage
  const response = await fetch("https://storage.example.com/upload", {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": file.type,
    },
    body: file,
  });
 
  return response.json().then((r) => r.url);
}

Secret-Scanning für Git

Secrets, die in die Versionskontrolle committet werden, bleiben in der Git-Historie erhalten, selbst nach dem Löschen. Verhindere, dass Commits mit Secrets überhaupt erst gepusht werden können.

tstypescript
// .pre-commit-config.yaml pattern for secret scanning
// Pre-commit hook that blocks secrets before they enter git history
 
interface SecretPattern {
  name: string;
  pattern: RegExp;
  severity: "block" | "warn";
}
 
const secretPatterns: SecretPattern[] = [
  {
    name: "AWS Access Key",
    pattern: /AKIA[0-9A-Z]{16}/,
    severity: "block",
  },
  {
    name: "Generic API Key",
    pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*['"][a-zA-Z0-9]{20,}['"]/i,
    severity: "block",
  },
  {
    name: "Private Key",
    pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/,
    severity: "block",
  },
  {
    name: "Stripe Secret Key",
    pattern: /sk_live_[a-zA-Z0-9]{20,}/,
    severity: "block",
  },
  {
    name: "JWT Secret",
    pattern: /(?:jwt[_-]?secret)\s*[:=]\s*['"][^'"]{10,}['"]/i,
    severity: "warn",
  },
];
 
function scanForSecrets(
  content: string,
  filename: string
): { found: boolean; matches: string[] } {
  const matches: string[] = [];
 
  for (const { name, pattern, severity } of secretPatterns) {
    if (pattern.test(content)) {
      matches.push(`[${severity.toUpperCase()}] ${name} found in ${filename}`);
    }
  }
 
  return { found: matches.length > 0, matches };
}

Content-Security-Policy-Header

Auch mit Backend-Proxys solltest du einschränken, wohin dein Frontend Anfragen senden darf. CSP-Header begrenzen den Schaden, falls ein Angreifer Code in deine Seite einschleust.

tstypescript
// next.config.ts — restrict API connections to known origins
const securityHeaders = [
  {
    key: "Content-Security-Policy",
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline'",
      "style-src 'self' 'unsafe-inline'",
      // Only allow API calls to your own backend and trusted CDNs
      "connect-src 'self' https://api.yourdomain.com",
      "img-src 'self' https://cdn.yourdomain.com data:",
      "font-src 'self'",
      "frame-src 'none'",
    ].join("; "),
  },
  {
    key: "X-Content-Type-Options",
    value: "nosniff",
  },
  {
    key: "Referrer-Policy",
    value: "strict-origin-when-cross-origin",
  },
];
 
// Apply to all routes
const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: securityHeaders,
      },
    ];
  },
};

Das Wichtigste in Kürze

Akzeptiere, dass Frontend-Code öffentlich ist, und gestalte deine Architektur entsprechend. Leite jeden API-Aufruf, der ein Secret enthält, über deinen eigenen Backend-Proxy—so kommt das Secret nie mit dem Client in Berührung. Setze frameworkspezifische Präfixe (NEXT_PUBLIC_, VITE_) bewusst ein: Dort gehören ausschließlich veröffentlichbare Schlüssel hin.

Wenn sich Clients direkt bei APIs von Drittanbietern authentifizieren müssen, erzeuge kurzlebige, eng begrenzte Tokens serverseitig, statt langlebige Schlüssel zu verteilen. Scanne in Pre-Commit-Hooks und CI-Pipelines nach Secrets, um eine versehentliche Offenlegung in der Git-Historie zu verhindern. Füge Content-Security-Policy-Header hinzu, um einzuschränken, wohin dein Frontend Netzwerkanfragen senden darf—das begrenzt den Schaden, falls deine Seite kompromittiert wird. Die Regel ist einfach: Wenn der Verlust eines Credentials Schaden anrichten würde, darf dieses Credential niemals in clientseitig zugänglichem Code existieren.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX