{"id":76,"date":"2026-06-13T16:37:23","date_gmt":"2026-06-13T16:37:23","guid":{"rendered":"https:\/\/shattered.io\/dk\/2026\/06\/13\/sikker-session-nodejs\/"},"modified":"2026-06-13T20:25:56","modified_gmt":"2026-06-13T20:25:56","slug":"sikker-session-nodejs","status":"publish","type":"post","link":"https:\/\/shattered.io\/dk\/2026\/06\/13\/sikker-session-nodejs\/","title":{"rendered":"Sikker session i Node.js: 12 trin p\u00e5 30 min [2026]"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">En stj\u00e5let eller forudsigelig session-cookie giver en angriber direkte adgang til en brugers konto uden adgangskode. Derfor er <strong>sikker session i Node.js<\/strong> en af de vigtigste opgaver, du l\u00f8ser, f\u00f8r en webapp g\u00e5r i produktion. I denne tutorial bygger du en komplet Express 5-applikation med <code>express-session<\/code>, Redis-lagring, h\u00e6rdede cookie-flag, session-regenerering ved login og b\u00e5de idle- og absolut timeout. Du kan f\u00f8lge alle 12 trin p\u00e5 cirka 30 minutter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Vi skriver til danske og nordiske udviklere, der allerede kender JavaScript, men som vil have et produktionsklart m\u00f8nster i stedet for et &#8220;hello world&#8221;-eksempel. Hvert trin indeholder k\u00f8rende kode, forventet output og en forklaring p\u00e5, hvorfor valget er sikkert. Til sidst finder du en samlet projektfil, en liste over almindelige faldgruber og 8 fejlfindingspunkter, du kan bruge som opslagsv\u00e6rk.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"hvad-du-laerer-i-denne-node-js-session-tutorial\">Hvad du l\u00e6rer i denne Node.js session-tutorial<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">M\u00e5let er en webapp, hvor en bruger kan logge ind, holde en sikker session over flere requests og logge ud igen, uden at sessionen kan kapres eller fikseres. N\u00e5r du er f\u00e6rdig, har du implementeret de kontroller, som OWASP anbefaler for session management, og du forst\u00e5r, hvorfor hver enkelt indstilling betyder noget for en sikker session i Node.js.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>En Express 5-server med <code>express-session<\/code> konfigureret efter best practice.<\/li>\n<li>H\u00e6rdede cookie-flag: <code>httpOnly<\/code>, <code>secure<\/code>, <code>sameSite<\/code> og <code>__Host-<\/code>-prefiks.<\/li>\n<li>Redis som delt session-store, s\u00e5 appen kan skaleres til flere instanser.<\/li>\n<li>Regenerering af session-id ved login for at stoppe session fixation.<\/li>\n<li>Idle-timeout og absolut timeout, der begr\u00e6nser skaden ved en stj\u00e5let cookie.<\/li>\n<li>Sikker logout, der \u00f8del\u00e6gger sessionen b\u00e5de i browseren og p\u00e5 serveren.<\/li>\n<li>Rate limiting p\u00e5 login for at bremse brute-force-fors\u00f8g.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"forudsaetninger-og-versioner\">Foruds\u00e6tninger og versioner<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Brug en aktuel LTS-version af Node.js. Tjek din version, f\u00f8r du starter, s\u00e5 koden matcher denne guide. Alle pakkeversioner herunder er dem, der er aktuelle i 2026.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Komponent<\/th><th>Version (2026)<\/th><th>Rolle i projektet<\/th><\/tr><\/thead><tbody>\n<tr><td>Node.js<\/td><td>Nyeste LTS (22.x eller 24.x)<\/td><td>Runtime<\/td><\/tr>\n<tr><td>Express<\/td><td>5.1.x (standard p\u00e5 npm siden 31. marts 2025)<\/td><td>Webframework<\/td><\/tr>\n<tr><td>express-session<\/td><td>1.19.0<\/td><td>Session-middleware<\/td><\/tr>\n<tr><td>connect-redis<\/td><td>Nyeste<\/td><td>Redis session-store-adapter<\/td><\/tr>\n<tr><td>redis (node-redis)<\/td><td>Nyeste 4.x\/5.x<\/td><td>Redis-klient<\/td><\/tr>\n<tr><td>express-rate-limit<\/td><td>Nyeste<\/td><td>Brute-force-beskyttelse<\/td><\/tr>\n<tr><td>Redis-server<\/td><td>7.x eller nyere<\/td><td>Session-lagring<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Du skal ogs\u00e5 have en k\u00f8rende Redis-instans. Lokalt kan du starte en med Docker p\u00e5 \u00e9t sekund: <code>docker run -p 6379:6379 redis:7<\/code>. Express 5.1 blev standardversionen p\u00e5 npm den 31. marts 2025 og er aktivt vedligeholdt, s\u00e5 vi bygger direkte p\u00e5 den. <code>express-session<\/code> hentes mere end 4 millioner gange om ugen if\u00f8lge npm, hvilket g\u00f8r den til \u00f8kosystemets de facto-standard for sessions.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"hvorfor-session-sikkerhed-er-kritisk\">Hvorfor session-sikkerhed er kritisk<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">En session knytter en r\u00e6kke requests til den samme bruger. Browseren beviser sin identitet med en session-cookie, der indeholder et tilf\u00e6ldigt id. Serveren sl\u00e5r id&#8217;et op i sin store og finder den tilh\u00f8rende brugertilstand. Hele sikkerheden hviler derfor p\u00e5 to ting: at id&#8217;et er umuligt at g\u00e6tte, og at cookien ikke kan stj\u00e6les eller misbruges.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To angreb dominerer. Ved <strong>session fixation<\/strong> tvinger angriberen et kendt session-id ind i offerets browser, f\u00f8r login. Hvis serveren ikke skifter id ved login, deler angriberen nu en gyldig session med offeret. Ved <strong>session hijacking<\/strong> stj\u00e6ler angriberen en gyldig cookie, typisk via cross-site scripting eller en usikker HTTP-forbindelse, og afspiller den. Begge angreb omg\u00e5r adgangskoden fuldst\u00e6ndigt.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>S\u00e5rbarhed<\/th><th>Hvordan den udnyttes<\/th><th>Forsvar i denne guide<\/th><\/tr><\/thead><tbody>\n<tr><td>Session fixation<\/td><td>Angriber s\u00e6tter id f\u00f8r login<\/td><td><code>req.session.regenerate()<\/code> ved login<\/td><\/tr>\n<tr><td>Session hijacking<\/td><td>Cookie stj\u00e6les via XSS eller HTTP<\/td><td><code>httpOnly<\/code>, <code>secure<\/code>, <code>sameSite<\/code><\/td><\/tr>\n<tr><td>Svag entropi<\/td><td>Id kan g\u00e6ttes<\/td><td>St\u00e6rk <code>secret<\/code> (mindst 32 bytes)<\/td><\/tr>\n<tr><td>Langtlevende sessions<\/td><td>Stj\u00e5let cookie virker for l\u00e6nge<\/td><td>Idle- og absolut timeout<\/td><\/tr>\n<tr><td>Tabt tilstand ved restart<\/td><td>MemoryStore ryddes<\/td><td>Redis-store<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">God transportbeskyttelse er foruds\u00e6tningen for alt det andet. Hvis du endnu ikke har TLS p\u00e5 plads, s\u00e5 start med vores guide til <a href=\"https:\/\/shattered.io\/dk\/2026\/06\/11\/ssl-tls-certifikat-certbot-2026\/\">gratis SSL\/TLS-certifikat med Certbot<\/a>, og l\u00e6s eventuelt <a href=\"https:\/\/shattered.io\/dk\/2026\/06\/10\/https-og-tls\/\">HTTPS og TLS forklaret<\/a> for baggrunden.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-1-opsaet-projektet\">Trin 1: Ops\u00e6t projektet<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Opret en ny mappe, initialiser et npm-projekt og installer afh\u00e6ngighederne. Vi bruger ES-moduler, s\u00e5 vi s\u00e6tter <code>\"type\": \"module\"<\/code> i <code>package.json<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>mkdir sikker-session && cd sikker-session\nnpm init -y\nnpm pkg set type=module\nnpm install express express-session connect-redis redis express-rate-limit dotenv\nnpm install --save-dev nodemon<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Forventet output viser, at pakkerne tilf\u00f8jes, og at <code>node_modules<\/code> oprettes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>added 92 packages, and audited 93 packages in 4s\nfound 0 vulnerabilities<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Opret derefter en <code>.env<\/code>-fil til hemmeligheder. L\u00e6g den aldrig i Git. Gener\u00e9r en st\u00e6rk session-secret med Node.js&#8217; indbyggede crypto-modul, s\u00e5 den har rigelig entropi.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>node -e \"console.log(require('crypto').randomBytes(48).toString('hex'))\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Kopier resultatet ind i <code>.env<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SESSION_SECRET=din_lange_tilfaeldige_hex_streng_her\nREDIS_URL=redis:\/\/localhost:6379\nNODE_ENV=development\nPORT=3000<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-2-byg-en-grundlaeggende-express-5-server\">Trin 2: Byg en grundl\u00e6ggende Express 5-server<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start med en minimal server, s\u00e5 du kan bekr\u00e6fte, at alt k\u00f8rer, f\u00f8r vi tilf\u00f8jer sessions. Opret <code>app.js<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import 'dotenv\/config';\nimport express from 'express';\n\nconst app = express();\napp.use(express.urlencoded({ extended: false }));\napp.use(express.json());\n\n\/\/ Vigtigt bag en reverse proxy (nginx, Heroku, Render):\n\/\/ saa req.secure og secure-cookies fungerer korrekt.\napp.set('trust proxy', 1);\n\napp.get('\/', (req, res) => {\n  res.send('Serveren koerer');\n});\n\nconst port = process.env.PORT || 3000;\napp.listen(port, () => {\n  console.log(`Lytter paa http:\/\/localhost:${port}`);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Tilf\u00f8j et start-script i <code>package.json<\/code> og k\u00f8r serveren:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm pkg set scripts.dev=\"nodemon app.js\"\nnpm run dev<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Du b\u00f8r se <code>Lytter paa http:\/\/localhost:3000<\/code> i terminalen, og et bes\u00f8g p\u00e5 adressen viser &#8220;Serveren koerer&#8221;. <code>app.set('trust proxy', 1)<\/code> er afg\u00f8rende: uden den vil <code>secure<\/code>-cookies ikke blive sat, n\u00e5r appen st\u00e5r bag en proxy, der terminerer TLS.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-3-tilfoej-express-session-middleware\">Trin 3: Tilf\u00f8j express-session-middleware<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Nu kobler vi <code>express-session<\/code> p\u00e5. Vi starter med en sikker basiskonfiguration og udvider den i de n\u00e6ste trin. Bem\u00e6rk valgene <code>resave: false<\/code> og <code>saveUninitialized: false<\/code>, der undg\u00e5r un\u00f8dvendige skrivninger og tomme sessions.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import session from 'express-session';\n\napp.use(session({\n  name: 'sid',                 \/\/ skjul standardnavnet connect.sid\n  secret: process.env.SESSION_SECRET,\n  resave: false,               \/\/ skriv ikke uaendrede sessions tilbage\n  saveUninitialized: false,    \/\/ gem ikke tomme sessions (GDPR-venligt)\n  cookie: {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === 'production',\n    sameSite: 'lax',\n    maxAge: 1000 * 60 * 30     \/\/ 30 minutter\n  }\n}));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Tilf\u00f8j en testrute, der t\u00e6ller bes\u00f8g, s\u00e5 du kan se sessionen virke:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.get('\/taeller', (req, res) => {\n  req.session.visits = (req.session.visits || 0) + 1;\n  res.json({ visits: req.session.visits });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Genindl\u00e6s <code>\/taeller<\/code> et par gange. T\u00e6lleren stiger, fordi browseren sender den samme session-cookie hver gang:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{ \"visits\": 1 }\n{ \"visits\": 2 }\n{ \"visits\": 3 }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Ved at s\u00e6tte <code>saveUninitialized: false<\/code> undg\u00e5r du at s\u00e6tte en cookie for bes\u00f8gende, der endnu ikke har gjort noget. Det er b\u00e5de bedre for ydeevnen og mere i tr\u00e5d med GDPR, fordi du ikke lagrer data om brugere uden grund.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-4-haerd-cookie-flagene\">Trin 4: H\u00e6rd cookie-flagene<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Cookie-flagene afg\u00f8r, hvor sv\u00e6rt det er at stj\u00e6le eller misbruge sessionen. Hvert flag lukker en konkret angrebsvej. Gennemg\u00e5 tabellen, og s\u00f8rg for, at alle fire er sat korrekt i produktion.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Flag<\/th><th>V\u00e6rdi<\/th><th>Hvad det forhindrer<\/th><\/tr><\/thead><tbody>\n<tr><td><code>httpOnly<\/code><\/td><td><code>true<\/code><\/td><td>JavaScript kan ikke l\u00e6se cookien (XSS-tyveri)<\/td><\/tr>\n<tr><td><code>secure<\/code><\/td><td><code>true<\/code> i produktion<\/td><td>Cookie sendes kun over HTTPS<\/td><\/tr>\n<tr><td><code>sameSite<\/code><\/td><td><code>'lax'<\/code> eller <code>'strict'<\/code><\/td><td>Cookie sendes ikke ved de fleste cross-site requests<\/td><\/tr>\n<tr><td><code>maxAge<\/code><\/td><td>30 min<\/td><td>Begr\u00e6nser cookiens levetid<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"samesite-strict-lax-eller-none\">SameSite: Strict, Lax eller None<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SameSite<\/code> styrer, hvorn\u00e5r browseren sender cookien ved requests fra andre sites. Valget er en afvejning mellem sikkerhed og brugervenlighed.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>V\u00e6rdi<\/th><th>Adf\u00e6rd<\/th><th>Brug til<\/th><\/tr><\/thead><tbody>\n<tr><td><code>Strict<\/code><\/td><td>Sendes aldrig ved cross-site, heller ikke ved navigation<\/td><td>Bankapps, admin-paneler<\/td><\/tr>\n<tr><td><code>Lax<\/code><\/td><td>Sendes ved top-level navigation med GET<\/td><td>De fleste apps (god standard)<\/td><\/tr>\n<tr><td><code>None<\/code><\/td><td>Sendes altid, men kr\u00e6ver <code>Secure<\/code><\/td><td>Tredjeparts-embeds, cross-site API<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"brug-__host-prefikset-i-produktion\">Brug __Host- prefikset i produktion<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Et cookienavn, der starter med <code>__Host-<\/code>, tvinger browseren til at kr\u00e6ve <code>Secure<\/code>, forbyde et <code>Domain<\/code>-attribut og kr\u00e6ve <code>Path=\/<\/code>. Resultatet er en host-only cookie, som ikke kan s\u00e6ttes eller overskrives fra et subdom\u00e6ne. Det er et st\u00e6rkt forsvar mod cookie-injektion p\u00e5 tv\u00e6rs af subdom\u00e6ner.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.use(session({\n  name: process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid',\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  cookie: {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === 'production',\n    sameSite: 'lax',\n    path: '\/',          \/\/ kraevet af __Host-\n    maxAge: 1000 * 60 * 30\n  }\n}));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Bem\u00e6rk, at <code>__Host-<\/code> ikke virker p\u00e5 <code>localhost<\/code> uden HTTPS, s\u00e5 vi bruger kun prefikset i produktion. S\u00e6t aldrig et <code>Domain<\/code>-attribut sammen med <code>__Host-<\/code>, ellers afviser browseren cookien lydl\u00f8st.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-5-skift-til-redis-som-session-store\">Trin 5: Skift til Redis som session-store<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Standard-storen i <code>express-session<\/code> (MemoryStore) advarer selv om, at den ikke er beregnet til produktion. Den l\u00e6kker hukommelse, mister alle sessions ved en genstart og kan ikke deles mellem flere instanser. I produktion vil du have en ekstern store. Redis er det almindelige valg, fordi den er hurtig, holder sessions ude af Node.js-processen og deles p\u00e5 tv\u00e6rs af instanser bag en load balancer.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Store<\/th><th>Produktion?<\/th><th>Deles mellem instanser<\/th><th>Overlever restart<\/th><\/tr><\/thead><tbody>\n<tr><td>MemoryStore<\/td><td>Nej<\/td><td>Nej<\/td><td>Nej<\/td><\/tr>\n<tr><td>Redis (connect-redis)<\/td><td>Ja<\/td><td>Ja<\/td><td>Ja<\/td><\/tr>\n<tr><td>Database (SQL)<\/td><td>Ja, men langsommere<\/td><td>Ja<\/td><td>Ja<\/td><\/tr>\n<tr><td>Filsystem<\/td><td>Kun enkelt instans<\/td><td>Nej<\/td><td>Ja<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Opret en Redis-klient og kobl <code>connect-redis<\/code> p\u00e5. I den aktuelle <code>connect-redis<\/code> importerer du <code>RedisStore<\/code> direkte og giver den klienten:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import { createClient } from 'redis';\nimport { RedisStore } from 'connect-redis';\n\nconst redisClient = createClient({ url: process.env.REDIS_URL });\nredisClient.on('error', (err) => console.error('Redis-fejl:', err));\nawait redisClient.connect();\n\nconst redisStore = new RedisStore({\n  client: redisClient,\n  prefix: 'sess:',\n  ttl: 60 * 30        \/\/ sekunder, matcher cookie maxAge\n});\n\napp.use(session({\n  store: redisStore,\n  name: process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid',\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  cookie: {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === 'production',\n    sameSite: 'lax',\n    path: '\/',\n    maxAge: 1000 * 60 * 30\n  }\n}));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Bekr\u00e6ft, at sessionerne nu lander i Redis. K\u00f8r <code>\/taeller<\/code> og inspicer derefter Redis:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ redis-cli KEYS 'sess:*'\n1) \"sess:Hk3v9QpN2mLt8xRf....\"\n$ redis-cli TTL 'sess:Hk3v9QpN2mLt8xRf....'\n(integer) 1800<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Node-redis er den officielle klient og passer til de fleste ops\u00e6tninger. Skal du k\u00f8re Redis Cluster eller Sentinel, er ioredis et udbredt alternativ med st\u00e6rk underst\u00f8ttelse af de topologier. Brug den klient, din store-adapter underst\u00f8tter bedst.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-6-login-og-regenerering-af-session-id\">Trin 6: Login og regenerering af session-id<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Dette er det vigtigste sikkerhedstrin i hele guiden. N\u00e5r en bruger logger ind, skal du <strong>regenerere session-id&#8217;et<\/strong>, f\u00f8r du gemmer brugerdata. Det smider det gamle id v\u00e6k og udsteder et nyt, s\u00e5 et id, en angriber m\u00e5tte have fikseret f\u00f8r login, bliver ugyldigt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I et rigtigt projekt sl\u00e5r du brugeren op i en database og verificerer adgangskoden med en st\u00e6rk hash. L\u00e6s vores guide til <a href=\"https:\/\/shattered.io\/dk\/2026\/06\/10\/kodeordssikkerhed\/\">kodeordssikkerhed og hashing<\/a> for det. Her fokuserer vi p\u00e5 selve session-h\u00e5ndteringen med en forenklet brugerkontrol:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.post('\/login', (req, res) => {\n  const { brugernavn, adgangskode } = req.body;\n\n  \/\/ I produktion: slaa op i DB og verificer hash (fx Argon2 eller bcrypt)\n  const erGyldig = brugernavn === 'alice' && adgangskode === 'hemmelig';\n  if (!erGyldig) {\n    return res.status(401).json({ fejl: 'Forkert login' });\n  }\n\n  \/\/ Regenerer id'et FOER vi gemmer data -> stopper session fixation\n  req.session.regenerate((err) => {\n    if (err) return res.status(500).json({ fejl: 'Session-fejl' });\n\n    req.session.bruger = { navn: brugernavn, rolle: 'user' };\n    req.session.loginTid = Date.now();\n    req.session.sidsteAktivitet = Date.now();\n\n    req.session.save((err) => {\n      if (err) return res.status(500).json({ fejl: 'Kunne ikke gemme' });\n      res.json({ ok: true, bruger: req.session.bruger });\n    });\n  });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Test loginet med curl, og bem\u00e6rk, at cookien f\u00e5r et nyt id efter login:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ curl -i -X POST http:\/\/localhost:3000\/login \\\n  -H 'Content-Type: application\/json' \\\n  -d '{\"brugernavn\":\"alice\",\"adgangskode\":\"hemmelig\"}'\n\nHTTP\/1.1 200 OK\nSet-Cookie: sid=s%3AnytId....; Path=\/; HttpOnly; SameSite=Lax\n{\"ok\":true,\"bruger\":{\"navn\":\"alice\",\"rolle\":\"user\"}}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Den eksplicitte <code>req.session.save()<\/code> sikrer, at sessionen er skrevet til Redis, f\u00f8r svaret sendes. Det undg\u00e5r en sj\u00e6lden race, hvor det n\u00e6ste request n\u00e5r frem, f\u00f8r storen har gemt.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-7-idle-timeout-og-absolut-timeout\">Trin 7: Idle-timeout og absolut timeout<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">To timeouts begr\u00e6nser, hvor l\u00e6nge en stj\u00e5let cookie kan bruges. <strong>Idle-timeout<\/strong> logger brugeren ud efter en periode uden aktivitet. <strong>Absolut timeout<\/strong> s\u00e6tter et h\u00e5rdt loft p\u00e5 den samlede sessionslevetid, uanset aktivitet. OWASP anbefaler begge dele. Konkrete v\u00e6rdier er en politikbeslutning, men tabellen viser almindelige intervaller.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Apptype<\/th><th>Idle-timeout<\/th><th>Absolut timeout<\/th><\/tr><\/thead><tbody>\n<tr><td>Netbank \/ sundhed<\/td><td>5-15 min<\/td><td>1-4 timer<\/td><\/tr>\n<tr><td>F\u00f8lsom virksomhedsapp<\/td><td>15-30 min<\/td><td>4-8 timer<\/td><\/tr>\n<tr><td>Almindelig webapp<\/td><td>30-60 min<\/td><td>8-12 timer<\/td><\/tr>\n<tr><td>Lav risiko<\/td><td>Op til et par timer<\/td><td>Op til 24 timer<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Implementer begge i en middleware, der k\u00f8rer f\u00f8r dine beskyttede ruter. Den l\u00e6ser de tidsstempler, vi satte ved login, og \u00f8del\u00e6gger sessionen, hvis en af gr\u00e6nserne er overskredet:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const IDLE_MS = 1000 * 60 * 30;        \/\/ 30 min uden aktivitet\nconst ABSOLUT_MS = 1000 * 60 * 60 * 8; \/\/ 8 timer total\n\nfunction tjekTimeout(req, res, next) {\n  if (!req.session.bruger) return next();\n\n  const naa = Date.now();\n  const inaktiv = naa - (req.session.sidsteAktivitet || naa);\n  const alder = naa - (req.session.loginTid || naa);\n\n  if (inaktiv > IDLE_MS || alder > ABSOLUT_MS) {\n    return req.session.destroy(() => {\n      res.clearCookie('sid');\n      res.status(440).json({ fejl: 'Session udloebet, log ind igen' });\n    });\n  }\n\n  req.session.sidsteAktivitet = naa; \/\/ forny ved aktivitet\n  next();\n}\n\napp.use(tjekTimeout);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">HTTP-status 440 er et uofficielt, men udbredt signal for &#8220;session udl\u00f8bet&#8221;. Frontenden kan fange den og sende brugeren til login-siden. Den absolutte timeout kan ikke fornys, hvilket er hele pointen: selv en aktiv angriber kan ikke holde en kapret session i live for evigt.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-8-rolling-sessions-og-fornyelse\">Trin 8: Rolling sessions og fornyelse<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Med <code>rolling: true<\/code> nulstiller <code>express-session<\/code> cookiens udl\u00f8b ved hvert svar, s\u00e5 aktive brugere ikke pludselig logges ud midt i arbejdet. Kombineret med vores idle-tjek f\u00e5r du en glidende oplevelse: aktive brugere bliver, inaktive ryger ud.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.use(session({\n  store: redisStore,\n  name: process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid',\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  rolling: true,              \/\/ forny cookie-udloeb ved aktivitet\n  cookie: {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === 'production',\n    sameSite: 'lax',\n    path: '\/',\n    maxAge: 1000 * 60 * 30\n  }\n}));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">S\u00e6t ogs\u00e5 Redis-storens <code>ttl<\/code> til at matche <code>maxAge<\/code>. Ellers risikerer du, at cookien lever l\u00e6ngere end serverdataene, s\u00e5 brugeren har en &#8220;gyldig&#8221; cookie, der peger p\u00e5 en slettet session. N\u00e5r begge er 30 minutter, udl\u00f8ber de samtidigt.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-9-sikker-logout\">Trin 9: Sikker logout<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">En korrekt logout skal g\u00f8re tre ting: \u00f8del\u00e6gge sessionen p\u00e5 serveren, fjerne cookien i browseren og ikke efterlade noget, der kan genbruges. Mange implementeringer rydder kun cookien og glemmer serverdataene, s\u00e5 den gamle session lever videre i storen.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const COOKIE_NAVN = process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid';\n\napp.post('\/logout', (req, res) => {\n  req.session.destroy((err) => {\n    if (err) return res.status(500).json({ fejl: 'Kunne ikke logge ud' });\n    res.clearCookie(COOKIE_NAVN, { path: '\/' });\n    res.json({ ok: true });\n  });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>req.session.destroy()<\/code> fjerner posten i Redis, og <code>res.clearCookie()<\/code> beder browseren slette cookien. Bekr\u00e6ft, at n\u00f8glen forsvinder fra Redis:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ redis-cli KEYS 'sess:*'\n(empty array)<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-10-beskyt-ruter-med-auth-middleware\">Trin 10: Beskyt ruter med auth-middleware<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Saml adgangskontrollen \u00e9t sted i en lille middleware, som du s\u00e6tter foran beskyttede ruter. Det undg\u00e5r, at du glemmer tjekket p\u00e5 en enkelt rute, hvilket er en klassisk kilde til l\u00e6kage.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>function kraevLogin(req, res, next) {\n  if (!req.session.bruger) {\n    return res.status(401).json({ fejl: 'Login kraevet' });\n  }\n  next();\n}\n\nfunction kraevRolle(rolle) {\n  return (req, res, next) => {\n    if (req.session.bruger?.rolle !== rolle) {\n      return res.status(403).json({ fejl: 'Ingen adgang' });\n    }\n    next();\n  };\n}\n\napp.get('\/profil', kraevLogin, (req, res) => {\n  res.json({ bruger: req.session.bruger });\n});\n\napp.get('\/admin', kraevLogin, kraevRolle('admin'), (req, res) => {\n  res.json({ hemmelig: 'kun for admins' });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Test, at en ikke-logget bruger afvises:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ curl -i http:\/\/localhost:3000\/profil\nHTTP\/1.1 401 Unauthorized\n{\"fejl\":\"Login kraevet\"}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-11-rate-limiting-mod-brute-force\">Trin 11: Rate limiting mod brute-force<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Login-ruten er et oplagt m\u00e5l for brute-force og credential stuffing. <code>express-rate-limit<\/code> begr\u00e6nser antallet af fors\u00f8g pr. IP inden for et tidsvindue. L\u00e6g den specifikt p\u00e5 <code>\/login<\/code>, s\u00e5 normal trafik ikke bremses.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import rateLimit from 'express-rate-limit';\n\nconst loginLimiter = rateLimit({\n  windowMs: 1000 * 60 * 15,   \/\/ 15 minutter\n  max: 10,                    \/\/ maks 10 forsoeg pr. IP\n  standardHeaders: true,\n  legacyHeaders: false,\n  message: { fejl: 'For mange loginforsoeg, proev igen senere' }\n});\n\napp.post('\/login', loginLimiter, (req, res) => {\n  \/\/ ... login-logik fra trin 6\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Efter det 11. fors\u00f8g inden for 15 minutter svarer serveren med 429:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>HTTP\/1.1 429 Too Many Requests\nRateLimit-Limit: 10\nRateLimit-Remaining: 0\n{\"fejl\":\"For mange loginforsoeg, proev igen senere\"}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Bag flere instanser b\u00f8r rate-limiteren ogs\u00e5 bruge Redis som backend, s\u00e5 gr\u00e6nsen deles p\u00e5 tv\u00e6rs. Ellers t\u00e6ller hver instans for sig, og en angriber f\u00e5r reelt flere fors\u00f8g.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-12-test-og-endelig-haerdning\">Trin 12: Test og endelig h\u00e6rdning<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Inden produktion gennemg\u00e5r du denne tjekliste. Hvert punkt svarer til en kontrol, vi har bygget undervejs, og tilsammen udg\u00f8r de en sikker session i Node.js.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>secret<\/code> har mindst 32 bytes entropi og kommer fra en milj\u00f8variabel, ikke fra koden.<\/li>\n<li><code>httpOnly<\/code>, <code>secure<\/code> og <code>sameSite<\/code> er sat, og <code>__Host-<\/code>-prefikset bruges i produktion.<\/li>\n<li>Session-id regenereres ved login og ved rolleskift.<\/li>\n<li>B\u00e5de idle- og absolut timeout er aktive.<\/li>\n<li>Produktion bruger Redis, ikke MemoryStore.<\/li>\n<li>Logout kalder <code>destroy()<\/code> og <code>clearCookie()<\/code>.<\/li>\n<li>Login-ruten er rate-limited.<\/li>\n<li><code>trust proxy<\/code> er sat korrekt bag en reverse proxy.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Tilf\u00f8j sikkerhedsheaders med Helmet for et ekstra lag, og overvej en streng Content-Security-Policy, der lukker for inline-scripts. Det reducerer XSS-risikoen, som er den prim\u00e6re vej til at stj\u00e6le en <code>httpOnly<\/code>-cookie indirekte.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm install helmet\n\/\/ i app.js:\nimport helmet from 'helmet';\napp.use(helmet());<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"automatiseret-test-af-din-session-sikkerhed\">Automatiseret test af din session-sikkerhed<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Manuelle curl-tests er fine under udvikling, men du vil have en automatiseret suite, der fanger regressioner, hver gang nogen r\u00f8rer ved login-koden. Med <code>supertest<\/code> kan du k\u00f8re hele request-flowet i hukommelsen uden at starte en rigtig server. Installer den som dev-afh\u00e6ngighed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm install --save-dev supertest vitest<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Skriv derefter en test, der bekr\u00e6fter de tre vigtigste invarianter: at en ubeskyttet rute afviser uautentificerede brugere, at login s\u00e6tter en cookie, og at session-id&#8217;et skifter efter login. Den sidste test er din direkte kontrol mod session fixation.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import { test, expect } from 'vitest';\nimport request from 'supertest';\nimport { app } from '..\/app.js';\n\ntest('profil afviser uden login', async () => {\n  const svar = await request(app).get('\/profil');\n  expect(svar.status).toBe(401);\n});\n\ntest('login saetter en cookie og giver adgang', async () => {\n  const agent = request.agent(app);\n  const login = await agent.post('\/login')\n    .send({ brugernavn: 'alice', adgangskode: 'hemmelig' });\n  expect(login.status).toBe(200);\n  expect(login.headers['set-cookie']).toBeDefined();\n\n  const profil = await agent.get('\/profil');\n  expect(profil.status).toBe(200);\n  expect(profil.body.bruger.navn).toBe('alice');\n});\n\ntest('session-id skifter ved login (anti-fixation)', async () => {\n  const agent = request.agent(app);\n  const foer = await agent.get('\/taeller');\n  const cookieFoer = foer.headers['set-cookie']?.[0];\n  const login = await agent.post('\/login')\n    .send({ brugernavn: 'alice', adgangskode: 'hemmelig' });\n  const cookieEfter = login.headers['set-cookie']?.[0];\n  expect(cookieEfter).not.toBe(cookieFoer);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For at testen kan importere appen, skal du eksportere <code>app<\/code> fra <code>app.js<\/code> uden at kalde <code>app.listen()<\/code> i testmilj\u00f8et. Et almindeligt m\u00f8nster er at adskille ops\u00e6tning fra start: byg appen i \u00e9n fil, og kald <code>listen<\/code> i en separat <code>server.js<\/code>. S\u00e5 k\u00f8rer testene mod appen direkte, mens produktion starter serveren.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">K\u00f8r suiten med <code>npx vitest run<\/code>. Forventet output bekr\u00e6fter alle tre kontroller:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\u2713 profil afviser uden login\n\u2713 login saetter en cookie og giver adgang\n\u2713 session-id skifter ved login (anti-fixation)\n\nTest Files  1 passed (1)\n     Tests  3 passed (3)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">L\u00e6g suiten i din CI-pipeline, s\u00e5 ingen \u00e6ndring kan sl\u00e5 anti-fixation-beskyttelsen fra ubem\u00e6rket. En enkelt fejlagtig refaktorering af login-ruten er nok til at \u00e5bne hullet igen, og en automatiseret test er den billigste forsikring mod netop det.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"drift-og-overvaagning-i-produktion\">Drift og overv\u00e5gning i produktion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">En sikker session i Node.js slutter ikke ved koden. I drift skal du kunne se, hvad der sker med sessionerne, og reagere, n\u00e5r noget ser forkert ud. Det handler om tre ting: synlighed, kapacitet og beredskab.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"log-de-rigtige-haendelser\">Log de rigtige h\u00e6ndelser<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Log login, logout, regenerering og timeout-udl\u00f8b, men aldrig selve session-id&#8217;et eller adgangskoder. Et l\u00e6kket logfil med session-id&#8217;er er lige s\u00e5 slemt som et databrud, fordi id&#8217;erne kan afspilles direkte. Log i stedet en anonymiseret reference og brugerens id:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.post('\/login', loginLimiter, (req, res) => {\n  \/\/ ... validering og regenerate ...\n  console.log(JSON.stringify({\n    haendelse: 'login',\n    bruger: req.session.bruger.navn,\n    ip: req.ip,\n    tid: new Date().toISOString()\n  }));\n  \/\/ log ALDRIG req.sessionID eller adgangskode\n});<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hold-oeje-med-redis-kapacitet\">Hold \u00f8je med Redis-kapacitet<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hver aktiv session optager hukommelse i Redis. Med en TTL p\u00e5 30 minutter rydder Redis selv udl\u00f8bne sessions, men under et spike eller et angreb kan antallet eksplodere. Overv\u00e5g <code>used_memory<\/code> og antallet af <code>sess:*<\/code>-n\u00f8gler, og s\u00e6t en alarm, hvis det stiger unormalt hurtigt. Det kan v\u00e6re det f\u00f8rste tegn p\u00e5 et credential stuffing-angreb.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ redis-cli INFO keyspace\n# Keyspace\ndb0:keys=1842,expires=1842,avg_ttl=1521000\n\n$ redis-cli INFO memory | grep used_memory_human\nused_memory_human:24.18M<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Forhold mellem <code>keys<\/code> og <code>expires<\/code> b\u00f8r v\u00e6re t\u00e6t p\u00e5 1: alle session-n\u00f8gler skal have en TTL. Hvis tallene divergerer, l\u00e6kker du sessions, der aldrig udl\u00f8ber, hvilket b\u00e5de er et hukommelses- og et sikkerhedsproblem.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hav-et-beredskab-for-kompromittering\">Hav et beredskab for kompromittering<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hvis du opdager, at en angriber har f\u00e5et fat i sessioner, skal du kunne ugyldigg\u00f8re alt p\u00e5 \u00e9n gang. Med Redis er det enkelt: roter <code>SESSION_SECRET<\/code>, og ryd n\u00f8glerne med prefikset. Alle eksisterende cookies bliver ugyldige, og samtlige brugere skal logge ind igen.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Ugyldiggoer ALLE aktive sessions i en noedsituation\n$ redis-cli --scan --pattern 'sess:*' | xargs redis-cli DEL<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Det er en h\u00e5rd, men effektiv knap. Test den i et staging-milj\u00f8, s\u00e5 du ved pr\u00e6cis, hvad der sker, f\u00f8r du nogensinde f\u00e5r brug for den i en rigtig h\u00e6ndelse. Et ind\u00f8vet beredskab er forskellen p\u00e5 minutters og dages nedetid.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"almindelige-faldgruber\">Almindelige faldgruber<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Glemt session-regenerering.<\/strong> Uden <code>regenerate()<\/code> ved login st\u00e5r din app \u00e5ben for session fixation, selv om alt andet er korrekt. Det er den hyppigste fejl.<\/li>\n<li><strong>MemoryStore i produktion.<\/strong> Den l\u00e6kker hukommelse og mister alle sessions ved restart. Advarslen i loggen er reel, ikke kosmetisk.<\/li>\n<li><strong>secure-cookie uden trust proxy.<\/strong> Bag en TLS-terminerende proxy ser Express forbindelsen som HTTP og s\u00e6tter aldrig cookien. Resultatet: brugeren kan ikke logge ind, og der er ingen fejl.<\/li>\n<li><strong>Domain-attribut sammen med __Host-.<\/strong> Browseren afviser cookien lydl\u00f8st. Drop <code>Domain<\/code> helt for host-only cookies.<\/li>\n<li><strong>Cookie og store-TTL ude af sync.<\/strong> Hvis cookien lever l\u00e6ngere end Redis-n\u00f8glen, peger en gyldig cookie p\u00e5 ingenting, og brugeren oplever tilf\u00e6ldige logouts.<\/li>\n<li><strong>Hemmelighed i kildekoden.<\/strong> En <code>secret<\/code> i Git er kompromitteret for altid. Brug milj\u00f8variabler og roter ved mistanke.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"fejlfinding\">Fejlfinding<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Symptom<\/th><th>Sandsynlig \u00e5rsag<\/th><th>L\u00f8sning<\/th><\/tr><\/thead><tbody>\n<tr><td>Cookie s\u00e6ttes ikke i produktion<\/td><td><code>trust proxy<\/code> mangler<\/td><td>S\u00e6t <code>app.set('trust proxy', 1)<\/code><\/td><\/tr>\n<tr><td>Bruger logges ud ved hvert request<\/td><td>Ny session hver gang<\/td><td>Tjek at cookien faktisk sendes; kontroller <code>sameSite<\/code><\/td><\/tr>\n<tr><td>&#8220;connect ECONNREFUSED&#8221; til Redis<\/td><td>Redis k\u00f8rer ikke<\/td><td>Start Redis; tjek <code>REDIS_URL<\/code><\/td><\/tr>\n<tr><td>Sessions forsvinder ved restart<\/td><td>MemoryStore i brug<\/td><td>Skift til <code>connect-redis<\/code><\/td><\/tr>\n<tr><td>__Host-sid afvises i browseren<\/td><td>Mangler Secure eller har Domain<\/td><td>S\u00e6t <code>secure: true<\/code>, fjern <code>Domain<\/code><\/td><\/tr>\n<tr><td>Cookie virker ikke p\u00e5 localhost<\/td><td><code>secure: true<\/code> uden HTTPS<\/td><td>Brug <code>secure<\/code> kun i produktion<\/td><\/tr>\n<tr><td>T\u00e6ller stiger ikke<\/td><td><code>saveUninitialized: false<\/code> + ingen skrivning<\/td><td>Skriv til <code>req.session<\/code> f\u00f8r forventet persistens<\/td><\/tr>\n<tr><td>Race ved login<\/td><td>Svar sendt f\u00f8r <code>save()<\/code><\/td><td>Kald <code>req.session.save()<\/code> eksplicit<\/td><\/tr>\n<tr><td>Rate limit rammer alle bag proxy<\/td><td>Forkert klient-IP<\/td><td>Konfigurer <code>trust proxy<\/code> s\u00e5 IP l\u00e6ses korrekt<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"avancerede-tips\">Avancerede tips<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Bind sessionen til kontekst.<\/strong> Gem en hash af brugerens User-Agent ved login, og afvis requests, hvor den ikke matcher. Det g\u00f8r en stj\u00e5let cookie sv\u00e6rere at bruge fra en anden maskine. V\u00e6r varsom med at binde til IP, da mobilbrugere skifter netv\u00e6rk ofte.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Regener\u00e9r ogs\u00e5 ved privilegieskift.<\/strong> N\u00e5r en bruger skifter rolle eller h\u00e6ver sine rettigheder, s\u00e5 regener\u00e9r id&#8217;et igen. Det f\u00f8lger samme princip som login og lukker en mindre kendt fixation-vej.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Overv\u00e5g aktive sessions.<\/strong> Med Redis kan du liste alle <code>sess:*<\/code>-n\u00f8gler og bygge en &#8220;log ud overalt&#8221;-funktion, der sletter alle en brugers sessions p\u00e5 \u00e9n gang. Det er uvurderligt efter et databrud. L\u00e6s hvordan brud opst\u00e5r i vores guide til <a href=\"https:\/\/shattered.io\/dk\/2026\/06\/10\/datalaek\/\">datal\u00e6k og beskyttelse<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>T\u00e6nk p\u00e5 compliance.<\/strong> For mange danske og nordiske virksomheder er sikker sessionsh\u00e5ndtering en del af NIS2-kravene til adgangskontrol. Se vores gennemgang af <a href=\"https:\/\/shattered.io\/dk\/2026\/06\/12\/nis2-danmark-krav-2026\/\">NIS2 i Danmark<\/a> for konteksten.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"det-komplette-projekt\">Det komplette projekt<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Her er hele <code>app.js<\/code> samlet. Den indeholder alle 12 trin og er klar til at k\u00f8re, s\u00e5 l\u00e6nge Redis og din <code>.env<\/code> er p\u00e5 plads.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import 'dotenv\/config';\nimport express from 'express';\nimport session from 'express-session';\nimport helmet from 'helmet';\nimport rateLimit from 'express-rate-limit';\nimport { createClient } from 'redis';\nimport { RedisStore } from 'connect-redis';\n\nconst app = express();\napp.set('trust proxy', 1);\napp.use(helmet());\napp.use(express.urlencoded({ extended: false }));\napp.use(express.json());\n\nconst redisClient = createClient({ url: process.env.REDIS_URL });\nredisClient.on('error', (e) => console.error('Redis-fejl:', e));\nawait redisClient.connect();\n\nconst redisStore = new RedisStore({ client: redisClient, prefix: 'sess:', ttl: 60 * 30 });\nconst COOKIE_NAVN = process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid';\n\napp.use(session({\n  store: redisStore,\n  name: COOKIE_NAVN,\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  rolling: true,\n  cookie: {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === 'production',\n    sameSite: 'lax',\n    path: '\/',\n    maxAge: 1000 * 60 * 30\n  }\n}));\n\nconst IDLE_MS = 1000 * 60 * 30;\nconst ABSOLUT_MS = 1000 * 60 * 60 * 8;\n\nfunction tjekTimeout(req, res, next) {\n  if (!req.session.bruger) return next();\n  const naa = Date.now();\n  const inaktiv = naa - (req.session.sidsteAktivitet || naa);\n  const alder = naa - (req.session.loginTid || naa);\n  if (inaktiv > IDLE_MS || alder > ABSOLUT_MS) {\n    return req.session.destroy(() => {\n      res.clearCookie(COOKIE_NAVN, { path: '\/' });\n      res.status(440).json({ fejl: 'Session udloebet' });\n    });\n  }\n  req.session.sidsteAktivitet = naa;\n  next();\n}\napp.use(tjekTimeout);\n\nfunction kraevLogin(req, res, next) {\n  if (!req.session.bruger) return res.status(401).json({ fejl: 'Login kraevet' });\n  next();\n}\n\nconst loginLimiter = rateLimit({ windowMs: 1000 * 60 * 15, max: 10, standardHeaders: true, legacyHeaders: false });\n\napp.post('\/login', loginLimiter, (req, res) => {\n  const { brugernavn, adgangskode } = req.body;\n  const erGyldig = brugernavn === 'alice' && adgangskode === 'hemmelig';\n  if (!erGyldig) return res.status(401).json({ fejl: 'Forkert login' });\n  req.session.regenerate((err) => {\n    if (err) return res.status(500).json({ fejl: 'Session-fejl' });\n    req.session.bruger = { navn: brugernavn, rolle: 'user' };\n    req.session.loginTid = Date.now();\n    req.session.sidsteAktivitet = Date.now();\n    req.session.save((err) => {\n      if (err) return res.status(500).json({ fejl: 'Kunne ikke gemme' });\n      res.json({ ok: true, bruger: req.session.bruger });\n    });\n  });\n});\n\napp.get('\/profil', kraevLogin, (req, res) => res.json({ bruger: req.session.bruger }));\n\napp.post('\/logout', (req, res) => {\n  req.session.destroy((err) => {\n    if (err) return res.status(500).json({ fejl: 'Kunne ikke logge ud' });\n    res.clearCookie(COOKIE_NAVN, { path: '\/' });\n    res.json({ ok: true });\n  });\n});\n\nconst port = process.env.PORT || 3000;\napp.listen(port, () => console.log(`Lytter paa http:\/\/localhost:${port}`));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Det er et komplet, produktionsklart fundament. Du kan udvide det med en rigtig brugerdatabase, adgangskode-hashing og en frontend, men selve sessionssikkerheden er nu p\u00e5 plads. For kryptografisk baggrund om, hvordan tilf\u00e6ldige id&#8217;er og signaturer fungerer, kan du l\u00e6se vores tutorial om <a href=\"https:\/\/shattered.io\/dk\/2026\/06\/12\/ed25519-signaturer-nodejs\/\">Ed25519-signaturer i Node.js<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"relateret-indhold\">Relateret indhold<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/shattered.io\/dk\/2026\/06\/12\/ed25519-signaturer-nodejs\/\">Ed25519 i Node.js: signaturer i 12 trin<\/a><\/li>\n<li><a href=\"https:\/\/shattered.io\/dk\/2026\/06\/11\/ssl-tls-certifikat-certbot-2026\/\">Gratis SSL\/TLS-certifikat: 12 trin med Certbot<\/a><\/li>\n<li><a href=\"https:\/\/shattered.io\/dk\/2026\/06\/10\/kodeordssikkerhed\/\">Kodeordssikkerhed: l\u00e6ngde, hashing og 2FA<\/a><\/li>\n<li><a href=\"https:\/\/shattered.io\/dk\/2026\/06\/10\/https-og-tls\/\">HTTPS og TLS: s\u00e5dan beskyttes din forbindelse<\/a><\/li>\n<li><a href=\"https:\/\/shattered.io\/dk\/2026\/06\/12\/nis2-danmark-krav-2026\/\">NIS2 Danmark: krav og status<\/a><\/li>\n<li><a href=\"https:\/\/shattered.io\/dk\/2026\/06\/10\/datalaek\/\">Datal\u00e6k: s\u00e5dan opst\u00e5r de, og s\u00e5dan beskytter du dig<\/a><\/li>\n<li><a href=\"https:\/\/shattered.io\/dk\/2026\/06\/10\/security-hub\/\">Onlinesikkerhed: den samlede guide<\/a><\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"eksterne-kilder\">Eksterne kilder<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/cheatsheetseries.owasp.org\/cheatsheets\/Session_Management_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP Session Management Cheat Sheet<\/a><\/li>\n<li><a href=\"https:\/\/owasp.org\/www-community\/attacks\/Session_fixation\" target=\"_blank\" rel=\"noopener\">OWASP: Session Fixation<\/a><\/li>\n<li><a href=\"https:\/\/expressjs.com\/en\/resources\/middleware\/session.html\" target=\"_blank\" rel=\"noopener\">Officiel express-session-dokumentation<\/a><\/li>\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/HTTP\/Guides\/Cookies\" target=\"_blank\" rel=\"noopener\">MDN: HTTP-cookies<\/a><\/li>\n<li><a href=\"https:\/\/expressjs.com\/en\/blog\/2025-03-31-v5-1-latest-release.html\" target=\"_blank\" rel=\"noopener\">Express 5.1 release-annoncering<\/a><\/li>\n<li><a href=\"https:\/\/redis.io\/docs\/latest\/develop\/clients\/nodejs\/\" target=\"_blank\" rel=\"noopener\">Redis: Node.js-klientdokumentation<\/a><\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"ofte-stillede-spoergsmaal\">Ofte stillede sp\u00f8rgsm\u00e5l<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"skal-jeg-bruge-sessions-eller-jwt-til-login\">Skal jeg bruge sessions eller JWT til login?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Til klassiske webapps med en server, du selv styrer, er server-side sessions ofte det enkleste og sikreste valg. Du kan tilbagekalde en session med det samme ved at slette den i storen, hvilket er sv\u00e6rt med stateless JWT. Sessions med <code>httpOnly<\/code>-cookies undg\u00e5r ogs\u00e5, at tokens ligger tilg\u00e6ngelige for JavaScript.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvorfor-er-memorystore-ikke-god-nok\">Hvorfor er MemoryStore ikke god nok?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">MemoryStore gemmer sessions i Node.js-processens hukommelse. Den l\u00e6kker hukommelse over tid, mister alt ved en genstart og kan ikke deles mellem flere instanser. <code>express-session<\/code> advarer selv om, at den kun er beregnet til udvikling. Brug Redis eller en anden ekstern store i produktion.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvad-er-forskellen-paa-idle-og-absolut-timeout\">Hvad er forskellen p\u00e5 idle- og absolut timeout?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Idle-timeout logger brugeren ud efter en periode uden aktivitet og fornys hver gang, brugeren g\u00f8r noget. Absolut timeout s\u00e6tter et h\u00e5rdt loft p\u00e5 den samlede sessionslevetid og kan ikke fornys. Sammen begr\u00e6nser de, hvor l\u00e6nge en stj\u00e5let cookie kan misbruges.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvorfor-skal-jeg-regenerere-session-id-ved-login\">Hvorfor skal jeg regenerere session-id ved login?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hvis du ikke skifter id ved login, kan en angriber, der p\u00e5 forh\u00e5nd har fikseret et kendt id i offerets browser, dele den autentificerede session. <code>req.session.regenerate()<\/code> udsteder et nyt id og g\u00f8r det gamle ugyldigt. Det er det enkleste og vigtigste forsvar mod session fixation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvilken-samesite-vaerdi-boer-jeg-vaelge\">Hvilken SameSite-v\u00e6rdi b\u00f8r jeg v\u00e6lge?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Lax<\/code> er en god standard for de fleste apps: cookien sendes ved almindelig navigation, men ikke ved de fleste cross-site requests. Brug <code>Strict<\/code> til meget f\u00f8lsomme apps som admin-paneler. <code>None<\/code> kr\u00e6ver <code>Secure<\/code> og b\u00f8r kun bruges, hvis du bevidst har brug for cross-site cookies.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvad-goer-__host-prefikset\">Hvad g\u00f8r __Host- prefikset?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Et cookienavn med <code>__Host-<\/code> tvinger browseren til at kr\u00e6ve <code>Secure<\/code>, forbyde et <code>Domain<\/code>-attribut og kr\u00e6ve <code>Path=\/<\/code>. Cookien bliver host-only og kan ikke s\u00e6ttes eller overskrives fra et subdom\u00e6ne, hvilket beskytter mod cookie-injektion p\u00e5 tv\u00e6rs af subdom\u00e6ner.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"virker-denne-opsaetning-bag-en-load-balancer\">Virker denne ops\u00e6tning bag en load balancer?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja, s\u00e5 l\u00e6nge du bruger en delt store som Redis og s\u00e6tter <code>app.set('trust proxy', 1)<\/code>. Redis deler sessionerne mellem alle instanser, og <code>trust proxy<\/code> sikrer, at <code>secure<\/code>-cookies og klient-IP&#8217;er l\u00e6ses korrekt gennem proxyen.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>En stj\u00e5let eller forudsigelig session-cookie giver en angriber direkte adgang til en brugers konto uden adgangskode. Derfor er sikker session i Node.js en af de vigtigste opgaver, du l\u00f8ser, f\u00f8r\u2026<\/p>\n","protected":false},"author":7,"featured_media":77,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-76","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-security"],"_links":{"self":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts\/76","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/users\/7"}],"replies":[{"embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/comments?post=76"}],"version-history":[{"count":1,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts\/76\/revisions"}],"predecessor-version":[{"id":78,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts\/76\/revisions\/78"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/media\/77"}],"wp:attachment":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/media?parent=76"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/categories?post=76"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/tags?post=76"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}