Elke dag lekken honderden API keys in Next.js apps gebouwd met AI-tools zoals Cursor en v0. In 2025 alleen al werden 2.3 miljoen API keys publiek zichtbaar op GitHub, waarvan 68% binnen 24 uur werd misbruikt.
Als je met AI-generated code werkt, is de kans groot dat je keys onbedoeld in je frontend terechtkomen. Dit artikel laat je zien hoe je dit voorkomt, detecteert en herstelt.
⚡ TL;DR — Key Takeaways
- ✅
NEXT_PUBLIC_prefix = publiek zichtbaar (nooit voor secrets) - ✅ Verplaats alle API calls met keys naar server routes
- ✅ Gebruik Zod voor runtime validatie van environment variables
- ✅ Scan je repo automatisch met tools als GitGuardian of Shadow Guard
- ✅ Roteer keys onmiddellijk bij vermoeden van exposure
Tijd: 12 minuten | Niveau: Intermediate
📋 Inhoudsopgave
Het Risico: Wat kan er misgaan met gelekte API Keys?
Wanneer je API key uitlekt, gebeurt er dit binnen 24 uur:
| Type Key | Gemiddelde tijd tot misbruik | Potentiële schade |
|---|---|---|
| OpenAI API Key | 4-6 uur | €500-€5000+ onverwachte kosten, model training met malicious data |
| Stripe Secret Key | 2-4 uur | Terugbetalingen, fraude, account suspension |
| AWS Access Key | 1-2 uur | Cryptomining, data exfiltratie, €10.000+ kosten |
| Supabase service_role | 6-12 uur | Volledige database access, data deletion |
⚠️ Echt voorbeeld: Een indie hacker bouwde een AI image generator met Cursor. Zijn OpenAI key ($0.002 per image) werd gelekt via een NEXT_PUBLIC_ variable. Binnen 12 uur had een attacker 2.3 miljoen requests gedaan = €4.600 onverwachte kosten.
Fout #1: De NEXT_PUBLIC_ Valstrik
Het meest voorkomende probleem: AI-tools zoals Cursor gebruiken NEXT_PUBLIC_ variabelen om snel environment variables in je code te krijgen. Maar in Next.js betekent dit prefix dat de waarde publiek zichtbaar is in je client-side JavaScript.
Hoe AI dit vaak genereert (❌ FOUT)
// .env.local - ❌ DIT IS FOUT
NEXT_PUBLIC_OPENAI_API_KEY=sk-abc123...
// app/page.tsx - ❌ Key is zichtbaar in browser
export default function ChatPage() {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'gpt-4', messages: [...] }),
});
}
🔍 Zo ziet een attacker je key: Open DevTools → Network tab → Zoek naar je API call → De key staat in plaintext in de request headers. Of nog erger: bekijk de page source en zoek naar "NEXT_PUBLIC_".
De regel: NEXT_PUBLIC_ = Publiek
| ❌ Verkeerd (Publiek) | ✅ Correct (Server-only) |
|---|---|
NEXT_PUBLIC_OPENAI_KEY |
OPENAI_API_KEY |
NEXT_PUBLIC_STRIPE_SECRET |
STRIPE_SECRET_KEY |
NEXT_PUBLIC_SUPABASE_KEY |
SUPABASE_SERVICE_ROLE_KEY |
Fout #2: Client-Side API Calls met Secrets
Zelfs als je je keys correct configureert, kan AI nog steeds voorstellen om API calls direct vanuit de browser te doen. Dit is bijna altijd verkeerd voor betaalde of gevoelige APIs.
Wat AI vaak genereert (❌ FOUT)
// components/Chat.tsx - ❌ NOOIT DOEN
'use client';
export default function Chat() {
const sendMessage = async (message: string) => {
// Deze call gebeurt in de browser - key is zichtbaar!
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, // Werkt niet client-side
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: message }],
}),
});
return response.json();
};
}
De Oplossing: Server-Only Pattern ✅
Alle API calls met secrets moeten via server routes lopen. Hier is het correcte pattern:
Stap 1: Server Route (veilig)
// app/api/chat/route.ts - ✅ CORRECT
import { NextResponse } from 'next/server';
import OpenAI from 'openai';
// Key is alleen toegankelijk op de server
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // Geen NEXT_PUBLIC_ prefix!
});
export async function POST(req: Request) {
const { message } = await req.json();
// Valideer input
if (!message || typeof message !== 'string') {
return NextResponse.json(
{ error: 'Invalid input' },
{ status: 400 }
);
}
try {
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: message }],
max_tokens: 500, // Limiteer kosten!
});
return NextResponse.json({
response: completion.choices[0].message.content,
});
} catch (error) {
console.error('OpenAI error:', error);
return NextResponse.json(
{ error: 'Failed to generate response' },
{ status: 500 }
);
}
}
Stap 2: Client Component (geen secrets)
// components/Chat.tsx - ✅ CORRECT
'use client';
import { useState } from 'react';
export default function Chat() {
const [response, setResponse] = useState('');
const [loading, setLoading] = useState(false);
const sendMessage = async (message: string) => {
setLoading(true);
try {
// Call je eigen server route, niet OpenAI direct
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
const data = await res.json();
setResponse(data.response);
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
return (
{/* UI hier */}
);
}
Stap 3: Environment Validation (optioneel maar aanbevolen)
// lib/env.ts - Runtime validatie
import { z } from 'zod';
const envSchema = z.object({
OPENAI_API_KEY: z.string().min(1, 'OPENAI_API_KEY is required'),
STRIPE_SECRET_KEY: z.string().min(1, 'STRIPE_SECRET_KEY is required'),
// Voeg hier andere required keys toe
});
// Gooi error tijdens startup als keys ontbreken
envSchema.parse(process.env);
export const env = envSchema.parse(process.env);
Automatische Detectie van Gelekte Keys
Zelfs met de beste intenties kan een key lekken. Zet automatische detectie op:
1. GitGuardian (Gratis voor open source)
# .github/workflows/security.yml
name: Secret Detection
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: GitGuardian scan
uses: GitGuardian/ggshield-action@v1
env:
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}