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.

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.
// ❌ 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)}`);// 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.
// ❌ 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.
// ❌ 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.
// .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.
// 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.


