Als je een AI vraagt om "je app te koppelen aan Stripe, OpenAI of Supabase", zet hij de key vaak rechtstreeks in de frontendcode. Het werkt meteen, en precies daarom kijkt niemand er nog naar. Het probleem is simpel en absoluut: alles wat in de browser aankomt, is openbaar. Er is geen manier om een tekst te verbergen die de browser moet lezen. Deze gids legt uit waarom het gebeurt, hoe een aanvaller het in een minuut vindt, welke keys gevaarlijk zijn en welke niet, hoe je er zelf naar zoekt en wat je doet als er al een is gelekt.
Waarom de key in de browser belandt
Er zijn drie gebruikelijke routes. De eerste is het publieke voorvoegsel van omgevingsvariabelen: in Next.js wordt alles wat met NEXT_PUBLIC_ begint in de client-JavaScript gebakken, net als VITE_ in Vite, NUXT_PUBLIC_ in Nuxt of REACT_APP_ in Create React App. De AI ziet dat "de variabele niet aankomt" en plakt het voorvoegsel erop zodat hij wel aankomt. De tweede is de directe aanroep: de assistent schrijft fetch('https://api.openai.com/...') in een interfacecomponent met de key in de header, omdat dat het kortste voorbeeld is dat hij kent. De derde is een gedeeld constantenbestand, geïmporteerd door zowel server als client, waar iemand de key "tijdelijk" heeft laten staan.
In alle drie de gevallen is het resultaat hetzelfde: de key reist mee in een .js-bestand dat iedereen kan downloaden, en blijft achter in CDN-caches, in de browsergeschiedenis van je gebruikers en in de archieven van de crawlers die het internet afspeuren naar precies dat.
Hoe een aanvaller het in 60 seconden ziet
- DevTools. Open je site, druk op F12, ga naar het tabblad Sources, druk op Ctrl+Shift+F om in alle bestanden te zoeken en typ
sk_live,sk-ant,AKIA,service_roleof-----BEGIN. Komt het tevoorschijn, dan heeft hij hem al. - Broncode. Rechtsklik, paginabron bekijken, en elk gelinkt
.js-bestand openen. Bundles zijn geminificeerd maar niet versleuteld; een tekstzoekopdracht is genoeg. - Source maps. Publiceer je de
.map-bestanden, dan ziet hij naast de code ook de oorspronkelijke variabelenamen en de commentaren, inclusief de// TODO: naar de server verplaatsen. - Automatische scanners. Er zijn bots die nieuwe domeinen afgaan, hun JavaScript downloaden en elk keypatroon testen bij de bijbehorende aanbieder. Een AWS-key die in een bundle lekt, wordt binnen minuten gebruikt, niet binnen dagen.
Welke keys gevaarlijk zijn en wat er gebeurt als ze lekken
- Stripe
sk_live_. Laat je betalingen en terugbetalingen aanmaken en klantgegevens lezen. Echt geld. - OpenAI, Anthropic en vergelijkbare (
sk-…). Wie hem heeft, verbruikt op jouw rekening. Facturen van duizenden euro's door een key in een publieke repository zijn een verhaal dat zich blijft herhalen. - AWS
AKIA…en Google Cloud. Afhankelijk van de rechten: toegang tot buckets, databases en machines opstarten om op jouw kosten te minen. - Supabase
service_role. Omzeilt Row Level Security volledig: volledige lees- en schrijftoegang tot je database. De ernstigste van allemaal in vibe-coded apps. - Firebase-serviceaccounts (JSON). Equivalent van
service_role: volledige controle over het project. - E-mail- en sms-aanbieders (Resend, SendGrid, Twilio). Mails en berichten versturen uit jouw naam, oftewel phishing met jouw domein.
- Databaseverbindingsstrings met gebruiker en wachtwoord, en JWT-secrets: met het secret onderteken je sessies voor elke willekeurige gebruiker.
- Toegangstokens in
localStorage. Niet jouw keys maar die van je gebruikers, en elke XSS leest en steelt ze.
Welke keys wel naar de client mogen
Niet elke key is een secret. Sommige zijn ontworpen om in de browser te leven, zolang wat erachter zit goed is ingesteld:
- De
anon- ofpublishable-key van Supabase, beschermd door de RLS-policies van elke tabel. - De publieke key van Stripe (
pk_live_), die alleen betalingen start, nooit int. - De Firebase-configuratie (apiKey, projectId…), beschermd door de beveiligingsregels en, beter nog, door App Check.
- Een Google Maps-key die in de Google-console op je domein is beperkt.
- Analytics-ID's en ID's van widgets van derden.
De regel om ze te onderscheiden: een key mag naar de client als de aanbieder hem expliciet publiek noemt en als een vreemde er, zelfs met de key in handen, niets mee kan doen wat jij niet wilt. Bij twijfel is hij geheim.
Hoe je er zelf naar zoekt
Zoek in de gebouwde bundle naar bekende patronen. Met Next.js of Vite, na de build:
grep -rEo "sk_live_[0-9a-zA-Z]+|sk-ant-[0-9a-zA-Z-]+|AKIA[0-9A-Z]{16}|service_role|eyJhbGciOi[0-9a-zA-Z._-]+" .next/static dist 2>/dev/null | head
In de repository, voordat het de bundle bereikt, lopen tools als gitleaks of trufflehog de hele geschiedenis door, niet alleen de laatste commit. En in het dashboard van je host kijk je welke variabelen een publiek voorvoegsel dragen: elk daarvan wordt naar de browser gestuurd.
Drie plekken waar mensen niet kijken: componenten met 'use client' die een gedeeld configuratiebestand importeren; URL-parameters, waar soms een token in reist dat in logs en geschiedenis belandt; en publieke GitHub-repositories, waar de key al maanden kan staan ook al zit hij niet meer in de huidige code.
Zo los je het op: de key blijft op de server
Het juiste patroon is dat de browser met jouw server praat en jouw server met de aanbieder. Een eigen endpoint ontvangt de aanvraag, controleert de sessie van de gebruiker, past een gebruikslimiet toe en doet de aanroep met de key uit een omgevingsvariabele zonder publiek voorvoegsel. In Next.js:
// app/api/summarize/route.ts (alleen server)
import { getSession } from '@/lib/auth'
export async function POST(req: Request) {
const session = await getSession(req)
if (!session) return new Response('Unauthorized', { status: 401 })
const { text } = await req.json()
if (typeof text !== 'string' || text.length > 5000) {
return new Response('Bad request', { status: 400 })
}
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.OPENAI_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: text }] }),
})
return new Response(r.body, { headers: { 'Content-Type': 'application/json' } })
}
Met Supabase geldt hetzelfde idee: bewerkingen die service_role nodig hebben, horen in een Edge Function of je backend, nooit in de client. Met Firebase in een Cloud Function. En in elk geval heet de omgevingsvariabele OPENAI_API_KEY, niet NEXT_PUBLIC_OPENAI_API_KEY.
Voor sessietokens geef je de voorkeur aan HttpOnly-cookies boven localStorage: een HttpOnly-cookie is niet leesbaar vanuit JavaScript, dus een XSS kan hem niet stelen. Slaat je bibliotheek de sessie standaard op in localStorage, zoals de Supabase-browserclient doet, compenseer dan met een strikte CSP of gebruik de servervariant met cookies.
Als hij al gelekt is: roteren, niet verwijderen
De key uit de code halen heeft geen zin: hij zit al in caches, in de git-geschiedenis en waarschijnlijk in iemands handen. De juiste volgorde:
- Een nieuwe key aanmaken bij de aanbieder en op de server deployen.
- De oude intrekken, meteen daarna. Bij Stripe, OpenAI en AWS is dat één klik in het dashboard.
- Het gebruik nakijken: facturatie, API-logs en betalingen van de laatste dagen. Bij misbruik open je een ticket bij de aanbieder; soms schelden ze de kosten kwijt.
- Uitgavenlimieten en meldingen instellen bij elke aanbieder die het toestaat, zodat het de volgende keer minder pijn doet.
- Gaf de key toegang tot persoonsgegevens van gebruikers, beoordeel dan of je onder de AVG moet melden.
De git-geschiedenis opschonen met tools als BFG of git filter-repo is goede hygiëne, maar vervangt de rotatie niet.
Hoe Scanaris dit controleert
Scanaris downloadt de JavaScript die je site serveert, precies zoals een aanvaller zou doen, en zoekt naar keypatronen van de gebruikelijke aanbieders, JWT-tokens, webhook-URL's en verbindingsstrings. Het waarschuwt ook als je source maps bereikbaar zijn en als er gevoelige bestanden binnen handbereik liggen. Elke bevinding komt met een prompt om in je AI te plakken met de exacte fix: de aanroep naar de server verplaatsen en de key roteren. Zet je monitoring aan, dan waarschuwt het je wanneer een nieuwe vibe coding-sessie weer een key in de client zet. Het maakt deel uit van de lanceerchecklist en van de beveiligingsgids voor AI-gebouwde apps.
Veelgestelde vragen
Is de anon-key van Supabase een secret?
Nee. Hij is bedoeld voor de browser en wordt beschermd door de RLS van elke tabel. Wat wel geheim is, is service_role. Klopt je RLS niet, dan is het probleem niet de anon-key maar de policies.
En als de key beperkt is tot een domein?
Referer-beperkingen (zoals bij Google Maps) verminderen misbruik vanaf andere websites, maar niet vanuit scripts die de header vervalsen. Ze zijn prima voor keys met laag risico, niet voor keys die geld uitgeven of data lezen.
Is hem uit de repository verwijderen genoeg?
Nee. Een key die in een publieke bundle of een publieke commit heeft gestaan, is gecompromitteerd. Roteer hem altijd, en schoon daarna de geschiedenis op als je wilt.