Tror du også at SameSite har fikset din CSRF?
Forestil dig at du sidder og hygger med morgenkaffen, mens din browser i baggrunden overfører penge fra din konto, fordi du lige klikkede på et uskyldigt link. Du gjorde egentlig ikke noget “forkert”. Din browser gjorde bare præcis det, den er bygget til.
Det er CSRF. Og det er derfor vi bliver nødt til at være lidt mere bevidste om, hvad vores cookies egentlig går og laver på nettet.
CSRF på én sætning – hvad er det, angriberen udnytter?
CSRF (Cross-Site Request Forgery) er, når en angriber får brugerens browser til at sende en gyldig request til dit site, uden at brugeren faktisk mente det.
Nøglen er “gyldig”. Brugeren er logget ind, deres session-cookie sidder fint i browseren, og browseren sender den med helt automatisk. Angriberen behøver ikke kende cookien, de skal bare få browseren til at bruge den for dem.
Et lille, jordnært eksempel
Sig at du har et site https://bank.example med et endpoint:
POST /transfer
Body: { toAccount: "1234", amount: 500 }
Når en bruger logger ind, får de en session-cookie, f.eks.:
Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
Senere besøger brugeren et ondsindet site, der indeholder:
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="toAccount" value="attacker" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>
document.forms[0].submit();
</script>
Hvis SameSite-indstillingerne og browser-adfærden lige flugter uheldigt, så sender browseren en POST med brugerens rigtige session-cookie til banken. Banken ser bare en helt normal, autoriseret request.
Det afgørende spørgsmål – hvornår er du faktisk sårbar?
CSRF kræver et par ting for at være farligt. Hvis bare én brik mangler, falder angrebet fra hinanden:
- Du bruger noget, browseren sender automatisk (typisk cookies) til at genkende brugeren
- Du har endpoints, der ændrer state (overfør penge, slet noget, acceptér invitation) via HTTP-requests
- Browseren må godt sende de cookies sammen med cross-site requests til dit domæne
Hvis du i stedet bruger kun bearer tokens i Authorization-headeren, som frontend selv sætter, er angrebsfladen meget mindre. Et fremmed site kan ikke sætte en vilkårlig Authorization-header fra en almindelig browser.
Ikke alle state changes er lige farlige
Du behøver ikke blive paranoid og låse alt ned. Fokuser især på:
- POST/PUT/PATCH/DELETE, der ændrer data for en specifik bruger
- Handlinger, der flytter penge, rettigheder eller adgang
- “Én gang” handlinger, f.eks. acceptér vilkår, skift e-mail
En GET der viser din profil er sjældent spændende for en angriber. En POST der ændrer din e-mail til angriberens adresse er.
Før vs nu – hvordan dit setup ændrer din CSRF-strategi
Lad os tage tre typiske setups og stille dem op over for hinanden.
| Setup | Hvordan auth virker | CSRF-risiko | Typisk beskyttelse |
|---|---|---|---|
| (A) Klassisk server-render (Django, Laravel osv.) | Cookie-baseret session, HTML-forms sender POST direkte til server | Høj, hvis SameSite ikke tænkes igennem | Hidden anti-CSRF token per form + server-validering |
| (B) SPA + API på samme domæne | Cookie-baseret session eller HttpOnly JWT-cookie, fetch/XHR til API | Mellem til høj, afhænger af SameSite og subdomæner | CSRF-token via header, double submit cookie eller Origin-check |
(C) SPA på app.example + API på api.example |
Ofte cookies med Domain=.example.com eller cross-site cookies |
Højere, fordi SameSite ofte ender som None |
Stærkere CSRF-token strategi + streng Origin/Referer kontrol |
Et lille “valgkort” til dig
Hvis jeg skal grovsortere ud fra erfaring:
- A – klassisk server-render: brug frameworkets indbyggede CSRF (f.eks.
{% csrf_token %}i Django) og tjek at SameSite står fornuftigt - B – SPA + API samme origin: overvej entweder CSRF-token i header eller strict SameSite + Origin-check for de vigtigste endpoints
- C – forskellige subdomæner: regn med at SameSite ikke redder dig, byg en rigtig CSRF-token løsning
Hvis du vil nørde mere generel sikkerhed, har jeg også skrevet om hemmelighedshåndtering i artiklen “Secrets skal være kedelige, ikke spændende”, som spiller ret godt sammen med det her.
SameSite i praksis – Lax, Strict, None og hvad der stadig slipper igennem
SameSite på cookies er blevet sådan lidt “vi har sat det til Lax, så vi er vel sikre”. Ikke helt.
SameSite-typerne side om side
| SameSite | Hvad betyder det? | CSRF-effekt |
|---|---|---|
| Lax | Sender cookies ved same-site requests + top-level navigationer (GET-links, form GET) | Stopper nogle CSRF med POST via skjulte formularer, men ikke alle scenarier |
| Strict | Sender |
Stopper langt det meste CSRF, men kan give problemer med login flows fra andre domæner |
| None | Sender cookies også ved cross-site, kræver Secure |
Intet CSRF-forsvar i sig selv, du skal have anden beskyttelse |
Vigtige detaljer, der ofte bliver glemt
- Browsere har ændret default-adfærd. Mange sætter nu cookies til Lax, hvis du ikke selv skriver noget
- “Same-site” er ikke det samme som “same-origin”. Subdomæner tælles forskelligt alt efter kontekst
- Hvis du har et flow der går via en ekstern identity provider (OIDC, SSO osv.), kan Strict SameSite ødelægge oplevelsen
Jeg ser ofte folk tænke: “Vi sætter bare SameSite=None, så vores SPA på et andet domæne virker”, og så er der ingen CSRF-beskyttelse tilbage. Så er vi tilbage i 2010.
Tre anti-CSRF-strategier – før vs efter SameSite-bølgen
SameSite er fint, men jeg ville ikke nøjes med det. Her er tre klassiske greb, og hvornår de giver mening.
1. Anti-CSRF token i body eller header
Idéen: serveren genererer et random token, gemmer det i server-side sessionen og sender det til klienten. Klienten sender det tilbage ved state-changing requests. Angriberen kan ikke læse tokenet fra et andet domæne, så de kan ikke genskabe en gyldig request.
I en server-renderet app ser det typisk sådan her ud i en HTML-form:
<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
...
</form>
På serveren:
def handle_transfer(request):
form_token = request.POST["csrf_token"]
session_token = request.session["csrf_token"]
if form_token != session_token:
return HttpResponseForbidden()
... fortsæt ...
Fordel: gennemtestet, understøttes af mange frameworks, beskytter fint også når SameSite=None.
Ulempe: kræver lidt mere wiring i en SPA, hvor du ikke automatisk har forms.
2. Double submit cookie
Her gemmer du ikke CSRF-token i server-sessionen. I stedet:
- Server sætter en
csrf-tokencookie (ikke HttpOnly) - Klient læser cookie-værdien i JavaScript og sender den med i f.eks. en header
X-CSRF-Token - Serveren tjekker at token i cookie matcher token i header
// Browser (SPA)
const csrfToken = getCookie("csrf-token");
fetch("/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ toAccount, amount }),
});
På serveren (pseudokode):
def api_transfer(request):
cookie_token = request.cookies["csrf-token"]
header_token = request.headers["X-CSRF-Token"]
if cookie_token != header_token:
return 403
...
Tricket er, at en angriber kun kan få browseren til at sende cookien automatisk, men de kan ikke læse den fra JS på deres domæne. De kan derfor ikke udfylde headeren korrekt.
3. Origin/Referer checks
Et lidt mere overset greb: tjek Origin eller Referer headeren på dine POST/PUT/DELETE requests.
const allowedOrigins = [
"https://app.example.com",
"https://www.example.com",
];
function checkOrigin(req) {
const origin = req.headers["origin"] || req.headers["referer"];
if (!origin) return false;
return allowedOrigins.some((o) => origin.startsWith(o));
}
Det stopper CSRF-requests, fordi browseren vil sende en Origin/Referer fra angriberens domæne. Det kan dog blive bøvlet med proxies og nogle privacy-setups.
Implementation i en SPA med API – et konkret flow
Lad os tage et ret typisk setup:
- SPA på
https://app.example.com - API på
https://api.example.com - Login med HttpOnly session-cookie fra API’et
Vi går fra login til en sikker POST.
1. Login-endpointet
Bruger sender credentials til POST https://api.example.com/login. Serveren:
- Validerer bruger og password
- Sætter session-cookie med
SameSite=None; Secure; HttpOnly - Sætter en CSRF-cookie (ikke HttpOnly) med et random token
Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=None
Set-Cookie: csrfToken=RANDOM123; Path=/; Secure; SameSite=None
Tokenet kan være bundet til sessionen på serversiden, men ved double submit-varianten er det ikke et krav.
2. SPA’en efter login
SPA’en læser csrfToken cookien:
function getCookie(name) {
return document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(name + "="))
?.split("=")[1];
}
const csrfToken = getCookie("csrfToken");
Og bruger den til alle state-changing API-kald:
async function transfer(toAccount, amount) {
const res = await fetch("https://api.example.com/transfer", {
method: "POST",
credentials: "include", // send cookies med
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCookie("csrfToken"),
},
body: JSON.stringify({ toAccount, amount }),
});
if (!res.ok) {
throw new Error("Transfer failed");
}
}
3. Server-valideringen
På API-siden tjekker du både session og CSRF:
function handleTransfer(req, res) {
const session = getSession(req.cookies.sessionId);
if (!session) return res.status(401).end();
const csrfCookie = req.cookies.csrfToken;
const csrfHeader = req.headers["x-csrf-token"];
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
return res.status(403).json({ error: "Invalid CSRF token" });
}
// ... udfør selve overførslen ...
}
En angriber kan godt få browseren til at sende sessionId og csrfToken cookies i en POST til https://api.example.com/transfer. Men de kan ikke læse csrfToken for at sætte headeren.
Typiske fælder – før vs efter du forstår CSRF
Her er nogle fejl, jeg har set lidt for mange gange. Flere af dem har jeg også selv lavet.
“Vi har CORS, så vi er sikret mod CSRF”
CORS handler om, hvilke svar en browser giver JavaScript-kode på et fremmed domæne lov til at læse. CSRF handler om, at browseren sender en request på dine vegne. De to problemer er beslægtede, men ikke det samme.
En angriber behøver ikke kunne læse svaret for at lave skade. De skal bare kunne sende en gyldig request.
Så selvom du har en stram Access-Control-Allow-Origin, kan du stadig være vidt åben for CSRF.
GET der ændrer state
Ja, det findes stadig. Nogle gange i skjulte interne admin-interfaces, nogle gange i “det var bare en lille feature” land.
Hvis du har sådan noget som:
GET /deleteUser?id=123
GET /confirmEmail?token=abc
GET /toggleFeature?name=beta
… så er du et simpelt link på en fremmed side væk fra et CSRF-angreb. Browsere er ekstremt gode til at lave GET-requests for dig, bare du ser skævt på et billede eller et script-tag.
Løsning: hold GET idempotent og læsende. Brug POST/PUT/DELETE til ændringer, og beskyt dem.
“Et token til evig tid”
Jeg har set CSRF-token implementeringer hvor tokenet genereres ved første login og så aldrig ændres. Det virker teknisk, men:
- Hvis nogen får lækket det (XSS, log, screenshot), har de evigt gyldig ammunition
- Det er svært at skifte strategi senere uden at bryde alle sessions
Bedre mønster:
- Rotér CSRF-token når session roteres (login, password reset osv.)
- Overvej at udløbe token efter et vist tidsrum for særligt følsomme handlinger
“Vi beskytter kun formularer, ikke API’et”
I klassiske apps er det nemt at huske CSRF på HTML-forms via framework-hjælpere. Men i mange projekter lever API’et sit eget liv uden samme beskyttelse.
Problem: din SPA bruger API’et. En angriber kan ramme det direkte via fetch/form/pixel-requests, uden om dine pæne forms.
Sørg for at din CSRF-logik ligger der, hvor state rent faktisk ændres, ikke kun der hvor HTML-formen starter rejsen.
CSRF-tjekliste – 30 minutters review af dit projekt
Hvis du vil sanity-checke et eksisterende projekt, kan du tage denne lille tur igennem koden. Tænk det som et code review, bare for sikkerhed.
1. Find alle steder du ændrer state
- List alle endpoints med POST/PUT/PATCH/DELETE
- Tjek om der gemmes, slettes eller flyttes data
- Marker særligt følsomme handlinger (betaling, adgangsrettigheder)
2. Find ud af, hvad du bruger til auth
- Afhænger auth af cookies, der sendes automatisk af browseren?
- Bruger du også JWT i localStorage/sessionStorage?
- Er der endpoints, der både accepterer cookie og header-token?
Hvis cookies er en del af billedet, skal du tage CSRF alvorligt.
3. Tjek SameSite-indstillingerne
- Hvordan sættes
Set-Cookiefor dine auth-relaterede cookies? - Er der steder, hvor du sætter
SameSite=Nonebare for at “løse et login-problem”? - Bruger du Strict på et site med eksterne login flows eller subdomæner?
Se også MDN’s side om SameSite hvis du vil være helt skarp på browser-detaljerne.
4. Kig på POST-håndteringen
- Validerer du et CSRF-token per request?
- Ligger tjekket tæt på dit actual write (så det ikke kan blive glemt ét sted)?
- Logger du 403’ere med CSRF-fejl, så du kan opdage problemer?
5. Undersøg dine headers
- Tjek om dine klient-kald sætter en CSRF-header (f.eks.
X-CSRF-Token) - Se om dine state-changing endpoints læser og validerer headeren
- Overvej at tilføje Origin/Referer-check på de mest kritiske endpoints
6. Test et simpelt angreb
Den hurtigste reality check:
- Log ind på din app i en browser-tab
- Åbn en ny tab og lav en simpel HTML-fil lokalt, der laver en POST til dit mest følsomme endpoint med
<form>+ auto-submit JavaScript - Åbn den lokale fil i browseren og se, om noget ændrer sig i din app
Hvis det gør, har du en meget konkret opgave til næste sprint. Og hvis det ikke gør, så har du stadig lært noget om, hvordan din auth og CSRF hænger sammen i praksis.
Hvis du vil bygge videre på de her sikkerhedsvaner, hænger det godt sammen med at have styr på dit deployment-setup og hemmeligheder. Artiklerne om f.eks. CORS-fejl uden panik og hostingvalg spiller faktisk ret fint sammen med at få lukket hullerne omkring CSRF.








1 kommentar