En stjålet 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øser, før en webapp går i produktion. I denne tutorial bygger du en komplet Express 5-applikation med express-session, Redis-lagring, hærdede cookie-flag, session-regenerering ved login og både idle- og absolut timeout. Du kan følge alle 12 trin på cirka 30 minutter.

Vi skriver til danske og nordiske udviklere, der allerede kender JavaScript, men som vil have et produktionsklart mønster i stedet for et “hello world”-eksempel. Hvert trin indeholder kørende kode, forventet output og en forklaring på, hvorfor valget er sikkert. Til sidst finder du en samlet projektfil, en liste over almindelige faldgruber og 8 fejlfindingspunkter, du kan bruge som opslagsværk.

Hvad du lærer i denne Node.js session-tutorial

Målet 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år du er færdig, har du implementeret de kontroller, som OWASP anbefaler for session management, og du forstår, hvorfor hver enkelt indstilling betyder noget for en sikker session i Node.js.

  • En Express 5-server med express-session konfigureret efter best practice.
  • Hærdede cookie-flag: httpOnly, secure, sameSite og __Host--prefiks.
  • Redis som delt session-store, så appen kan skaleres til flere instanser.
  • Regenerering af session-id ved login for at stoppe session fixation.
  • Idle-timeout og absolut timeout, der begrænser skaden ved en stjålet cookie.
  • Sikker logout, der ødelægger sessionen både i browseren og på serveren.
  • Rate limiting på login for at bremse brute-force-forsøg.

Forudsætninger og versioner

Brug en aktuel LTS-version af Node.js. Tjek din version, før du starter, så koden matcher denne guide. Alle pakkeversioner herunder er dem, der er aktuelle i 2026.

KomponentVersion (2026)Rolle i projektet
Node.jsNyeste LTS (22.x eller 24.x)Runtime
Express5.1.x (standard på npm siden 31. marts 2025)Webframework
express-session1.19.0Session-middleware
connect-redisNyesteRedis session-store-adapter
redis (node-redis)Nyeste 4.x/5.xRedis-klient
express-rate-limitNyesteBrute-force-beskyttelse
Redis-server7.x eller nyereSession-lagring

Du skal også have en kørende Redis-instans. Lokalt kan du starte en med Docker på ét sekund: docker run -p 6379:6379 redis:7. Express 5.1 blev standardversionen på npm den 31. marts 2025 og er aktivt vedligeholdt, så vi bygger direkte på den. express-session hentes mere end 4 millioner gange om ugen ifølge npm, hvilket gør den til økosystemets de facto-standard for sessions.

Hvorfor session-sikkerhed er kritisk

En session knytter en række requests til den samme bruger. Browseren beviser sin identitet med en session-cookie, der indeholder et tilfældigt id. Serveren slår id’et op i sin store og finder den tilhørende brugertilstand. Hele sikkerheden hviler derfor på to ting: at id’et er umuligt at gætte, og at cookien ikke kan stjæles eller misbruges.

To angreb dominerer. Ved session fixation tvinger angriberen et kendt session-id ind i offerets browser, før login. Hvis serveren ikke skifter id ved login, deler angriberen nu en gyldig session med offeret. Ved session hijacking stjæler angriberen en gyldig cookie, typisk via cross-site scripting eller en usikker HTTP-forbindelse, og afspiller den. Begge angreb omgår adgangskoden fuldstændigt.

SårbarhedHvordan den udnyttesForsvar i denne guide
Session fixationAngriber sætter id før loginreq.session.regenerate() ved login
Session hijackingCookie stjæles via XSS eller HTTPhttpOnly, secure, sameSite
Svag entropiId kan gættesStærk secret (mindst 32 bytes)
Langtlevende sessionsStjålet cookie virker for længeIdle- og absolut timeout
Tabt tilstand ved restartMemoryStore ryddesRedis-store

God transportbeskyttelse er forudsætningen for alt det andet. Hvis du endnu ikke har TLS på plads, så start med vores guide til gratis SSL/TLS-certifikat med Certbot, og læs eventuelt HTTPS og TLS forklaret for baggrunden.

Trin 1: Opsæt projektet

Opret en ny mappe, initialiser et npm-projekt og installer afhængighederne. Vi bruger ES-moduler, så vi sætter "type": "module" i package.json.

mkdir sikker-session && cd sikker-session
npm init -y
npm pkg set type=module
npm install express express-session connect-redis redis express-rate-limit dotenv
npm install --save-dev nodemon

Forventet output viser, at pakkerne tilføjes, og at node_modules oprettes:

added 92 packages, and audited 93 packages in 4s
found 0 vulnerabilities

Opret derefter en .env-fil til hemmeligheder. Læg den aldrig i Git. Generér en stærk session-secret med Node.js’ indbyggede crypto-modul, så den har rigelig entropi.

node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

Kopier resultatet ind i .env:

SESSION_SECRET=din_lange_tilfaeldige_hex_streng_her
REDIS_URL=redis://localhost:6379
NODE_ENV=development
PORT=3000

Trin 2: Byg en grundlæggende Express 5-server

Start med en minimal server, så du kan bekræfte, at alt kører, før vi tilføjer sessions. Opret app.js:

import 'dotenv/config';
import express from 'express';

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

// Vigtigt bag en reverse proxy (nginx, Heroku, Render):
// saa req.secure og secure-cookies fungerer korrekt.
app.set('trust proxy', 1);

app.get('/', (req, res) => {
  res.send('Serveren koerer');
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Lytter paa http://localhost:${port}`);
});

Tilføj et start-script i package.json og kør serveren:

npm pkg set scripts.dev="nodemon app.js"
npm run dev

Du bør se Lytter paa http://localhost:3000 i terminalen, og et besøg på adressen viser “Serveren koerer”. app.set('trust proxy', 1) er afgørende: uden den vil secure-cookies ikke blive sat, når appen står bag en proxy, der terminerer TLS.

Trin 3: Tilføj express-session-middleware

Nu kobler vi express-session på. Vi starter med en sikker basiskonfiguration og udvider den i de næste trin. Bemærk valgene resave: false og saveUninitialized: false, der undgår unødvendige skrivninger og tomme sessions.

import session from 'express-session';

app.use(session({
  name: 'sid',                 // skjul standardnavnet connect.sid
  secret: process.env.SESSION_SECRET,
  resave: false,               // skriv ikke uaendrede sessions tilbage
  saveUninitialized: false,    // gem ikke tomme sessions (GDPR-venligt)
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 1000 * 60 * 30     // 30 minutter
  }
}));

Tilføj en testrute, der tæller besøg, så du kan se sessionen virke:

app.get('/taeller', (req, res) => {
  req.session.visits = (req.session.visits || 0) + 1;
  res.json({ visits: req.session.visits });
});

Genindlæs /taeller et par gange. Tælleren stiger, fordi browseren sender den samme session-cookie hver gang:

{ "visits": 1 }
{ "visits": 2 }
{ "visits": 3 }

Ved at sætte saveUninitialized: false undgår du at sætte en cookie for besøgende, der endnu ikke har gjort noget. Det er både bedre for ydeevnen og mere i tråd med GDPR, fordi du ikke lagrer data om brugere uden grund.

Cookie-flagene afgør, hvor svært det er at stjæle eller misbruge sessionen. Hvert flag lukker en konkret angrebsvej. Gennemgå tabellen, og sørg for, at alle fire er sat korrekt i produktion.

FlagVærdiHvad det forhindrer
httpOnlytrueJavaScript kan ikke læse cookien (XSS-tyveri)
securetrue i produktionCookie sendes kun over HTTPS
sameSite'lax' eller 'strict'Cookie sendes ikke ved de fleste cross-site requests
maxAge30 minBegrænser cookiens levetid

SameSite: Strict, Lax eller None

SameSite styrer, hvornår browseren sender cookien ved requests fra andre sites. Valget er en afvejning mellem sikkerhed og brugervenlighed.

VærdiAdfærdBrug til
StrictSendes aldrig ved cross-site, heller ikke ved navigationBankapps, admin-paneler
LaxSendes ved top-level navigation med GETDe fleste apps (god standard)
NoneSendes altid, men kræver SecureTredjeparts-embeds, cross-site API

Brug __Host- prefikset i produktion

Et cookienavn, der starter med __Host-, tvinger browseren til at kræve Secure, forbyde et Domain-attribut og kræve Path=/. Resultatet er en host-only cookie, som ikke kan sættes eller overskrives fra et subdomæne. Det er et stærkt forsvar mod cookie-injektion på tværs af subdomæner.

app.use(session({
  name: process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',          // kraevet af __Host-
    maxAge: 1000 * 60 * 30
  }
}));

Bemærk, at __Host- ikke virker på localhost uden HTTPS, så vi bruger kun prefikset i produktion. Sæt aldrig et Domain-attribut sammen med __Host-, ellers afviser browseren cookien lydløst.

Trin 5: Skift til Redis som session-store

Standard-storen i express-session (MemoryStore) advarer selv om, at den ikke er beregnet til produktion. Den lækker 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å tværs af instanser bag en load balancer.

StoreProduktion?Deles mellem instanserOverlever restart
MemoryStoreNejNejNej
Redis (connect-redis)JaJaJa
Database (SQL)Ja, men langsommereJaJa
FilsystemKun enkelt instansNejJa

Opret en Redis-klient og kobl connect-redis på. I den aktuelle connect-redis importerer du RedisStore direkte og giver den klienten:

import { createClient } from 'redis';
import { RedisStore } from 'connect-redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.on('error', (err) => console.error('Redis-fejl:', err));
await redisClient.connect();

const redisStore = new RedisStore({
  client: redisClient,
  prefix: 'sess:',
  ttl: 60 * 30        // sekunder, matcher cookie maxAge
});

app.use(session({
  store: redisStore,
  name: process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 1000 * 60 * 30
  }
}));

Bekræft, at sessionerne nu lander i Redis. Kør /taeller og inspicer derefter Redis:

$ redis-cli KEYS 'sess:*'
1) "sess:Hk3v9QpN2mLt8xRf...."
$ redis-cli TTL 'sess:Hk3v9QpN2mLt8xRf....'
(integer) 1800

Node-redis er den officielle klient og passer til de fleste opsætninger. Skal du køre Redis Cluster eller Sentinel, er ioredis et udbredt alternativ med stærk understøttelse af de topologier. Brug den klient, din store-adapter understøtter bedst.

Trin 6: Login og regenerering af session-id

Dette er det vigtigste sikkerhedstrin i hele guiden. Når en bruger logger ind, skal du regenerere session-id’et, før du gemmer brugerdata. Det smider det gamle id væk og udsteder et nyt, så et id, en angriber måtte have fikseret før login, bliver ugyldigt.

I et rigtigt projekt slår du brugeren op i en database og verificerer adgangskoden med en stærk hash. Læs vores guide til kodeordssikkerhed og hashing for det. Her fokuserer vi på selve session-håndteringen med en forenklet brugerkontrol:

app.post('/login', (req, res) => {
  const { brugernavn, adgangskode } = req.body;

  // I produktion: slaa op i DB og verificer hash (fx Argon2 eller bcrypt)
  const erGyldig = brugernavn === 'alice' && adgangskode === 'hemmelig';
  if (!erGyldig) {
    return res.status(401).json({ fejl: 'Forkert login' });
  }

  // Regenerer id'et FOER vi gemmer data -> stopper session fixation
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ fejl: 'Session-fejl' });

    req.session.bruger = { navn: brugernavn, rolle: 'user' };
    req.session.loginTid = Date.now();
    req.session.sidsteAktivitet = Date.now();

    req.session.save((err) => {
      if (err) return res.status(500).json({ fejl: 'Kunne ikke gemme' });
      res.json({ ok: true, bruger: req.session.bruger });
    });
  });
});

Test loginet med curl, og bemærk, at cookien får et nyt id efter login:

$ curl -i -X POST http://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{"brugernavn":"alice","adgangskode":"hemmelig"}'

HTTP/1.1 200 OK
Set-Cookie: sid=s%3AnytId....; Path=/; HttpOnly; SameSite=Lax
{"ok":true,"bruger":{"navn":"alice","rolle":"user"}}

Den eksplicitte req.session.save() sikrer, at sessionen er skrevet til Redis, før svaret sendes. Det undgår en sjælden race, hvor det næste request når frem, før storen har gemt.

Trin 7: Idle-timeout og absolut timeout

To timeouts begrænser, hvor længe en stjålet cookie kan bruges. Idle-timeout logger brugeren ud efter en periode uden aktivitet. Absolut timeout sætter et hårdt loft på den samlede sessionslevetid, uanset aktivitet. OWASP anbefaler begge dele. Konkrete værdier er en politikbeslutning, men tabellen viser almindelige intervaller.

ApptypeIdle-timeoutAbsolut timeout
Netbank / sundhed5-15 min1-4 timer
Følsom virksomhedsapp15-30 min4-8 timer
Almindelig webapp30-60 min8-12 timer
Lav risikoOp til et par timerOp til 24 timer

Implementer begge i en middleware, der kører før dine beskyttede ruter. Den læser de tidsstempler, vi satte ved login, og ødelægger sessionen, hvis en af grænserne er overskredet:

const IDLE_MS = 1000 * 60 * 30;        // 30 min uden aktivitet
const ABSOLUT_MS = 1000 * 60 * 60 * 8; // 8 timer total

function tjekTimeout(req, res, next) {
  if (!req.session.bruger) return next();

  const naa = Date.now();
  const inaktiv = naa - (req.session.sidsteAktivitet || naa);
  const alder = naa - (req.session.loginTid || naa);

  if (inaktiv > IDLE_MS || alder > ABSOLUT_MS) {
    return req.session.destroy(() => {
      res.clearCookie('sid');
      res.status(440).json({ fejl: 'Session udloebet, log ind igen' });
    });
  }

  req.session.sidsteAktivitet = naa; // forny ved aktivitet
  next();
}

app.use(tjekTimeout);

HTTP-status 440 er et uofficielt, men udbredt signal for “session udløbet”. 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.

Trin 8: Rolling sessions og fornyelse

Med rolling: true nulstiller express-session cookiens udløb ved hvert svar, så aktive brugere ikke pludselig logges ud midt i arbejdet. Kombineret med vores idle-tjek får du en glidende oplevelse: aktive brugere bliver, inaktive ryger ud.

app.use(session({
  store: redisStore,
  name: process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  rolling: true,              // forny cookie-udloeb ved aktivitet
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 1000 * 60 * 30
  }
}));

Sæt også Redis-storens ttl til at matche maxAge. Ellers risikerer du, at cookien lever længere end serverdataene, så brugeren har en “gyldig” cookie, der peger på en slettet session. Når begge er 30 minutter, udløber de samtidigt.

Trin 9: Sikker logout

En korrekt logout skal gøre tre ting: ødelægge sessionen på serveren, fjerne cookien i browseren og ikke efterlade noget, der kan genbruges. Mange implementeringer rydder kun cookien og glemmer serverdataene, så den gamle session lever videre i storen.

const COOKIE_NAVN = process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid';

app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ fejl: 'Kunne ikke logge ud' });
    res.clearCookie(COOKIE_NAVN, { path: '/' });
    res.json({ ok: true });
  });
});

req.session.destroy() fjerner posten i Redis, og res.clearCookie() beder browseren slette cookien. Bekræft, at nøglen forsvinder fra Redis:

$ redis-cli KEYS 'sess:*'
(empty array)

Trin 10: Beskyt ruter med auth-middleware

Saml adgangskontrollen ét sted i en lille middleware, som du sætter foran beskyttede ruter. Det undgår, at du glemmer tjekket på en enkelt rute, hvilket er en klassisk kilde til lækage.

function kraevLogin(req, res, next) {
  if (!req.session.bruger) {
    return res.status(401).json({ fejl: 'Login kraevet' });
  }
  next();
}

function kraevRolle(rolle) {
  return (req, res, next) => {
    if (req.session.bruger?.rolle !== rolle) {
      return res.status(403).json({ fejl: 'Ingen adgang' });
    }
    next();
  };
}

app.get('/profil', kraevLogin, (req, res) => {
  res.json({ bruger: req.session.bruger });
});

app.get('/admin', kraevLogin, kraevRolle('admin'), (req, res) => {
  res.json({ hemmelig: 'kun for admins' });
});

Test, at en ikke-logget bruger afvises:

$ curl -i http://localhost:3000/profil
HTTP/1.1 401 Unauthorized
{"fejl":"Login kraevet"}

Trin 11: Rate limiting mod brute-force

Login-ruten er et oplagt mål for brute-force og credential stuffing. express-rate-limit begrænser antallet af forsøg pr. IP inden for et tidsvindue. Læg den specifikt på /login, så normal trafik ikke bremses.

import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 1000 * 60 * 15,   // 15 minutter
  max: 10,                    // maks 10 forsoeg pr. IP
  standardHeaders: true,
  legacyHeaders: false,
  message: { fejl: 'For mange loginforsoeg, proev igen senere' }
});

app.post('/login', loginLimiter, (req, res) => {
  // ... login-logik fra trin 6
});

Efter det 11. forsøg inden for 15 minutter svarer serveren med 429:

HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 10
RateLimit-Remaining: 0
{"fejl":"For mange loginforsoeg, proev igen senere"}

Bag flere instanser bør rate-limiteren også bruge Redis som backend, så grænsen deles på tværs. Ellers tæller hver instans for sig, og en angriber får reelt flere forsøg.

Trin 12: Test og endelig hærdning

Inden produktion gennemgår du denne tjekliste. Hvert punkt svarer til en kontrol, vi har bygget undervejs, og tilsammen udgør de en sikker session i Node.js.

  • secret har mindst 32 bytes entropi og kommer fra en miljøvariabel, ikke fra koden.
  • httpOnly, secure og sameSite er sat, og __Host--prefikset bruges i produktion.
  • Session-id regenereres ved login og ved rolleskift.
  • Både idle- og absolut timeout er aktive.
  • Produktion bruger Redis, ikke MemoryStore.
  • Logout kalder destroy() og clearCookie().
  • Login-ruten er rate-limited.
  • trust proxy er sat korrekt bag en reverse proxy.

Tilføj 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ære vej til at stjæle en httpOnly-cookie indirekte.

npm install helmet
// i app.js:
import helmet from 'helmet';
app.use(helmet());

Automatiseret test af din session-sikkerhed

Manuelle curl-tests er fine under udvikling, men du vil have en automatiseret suite, der fanger regressioner, hver gang nogen rører ved login-koden. Med supertest kan du køre hele request-flowet i hukommelsen uden at starte en rigtig server. Installer den som dev-afhængighed:

npm install --save-dev supertest vitest

Skriv derefter en test, der bekræfter de tre vigtigste invarianter: at en ubeskyttet rute afviser uautentificerede brugere, at login sætter en cookie, og at session-id’et skifter efter login. Den sidste test er din direkte kontrol mod session fixation.

import { test, expect } from 'vitest';
import request from 'supertest';
import { app } from '../app.js';

test('profil afviser uden login', async () => {
  const svar = await request(app).get('/profil');
  expect(svar.status).toBe(401);
});

test('login saetter en cookie og giver adgang', async () => {
  const agent = request.agent(app);
  const login = await agent.post('/login')
    .send({ brugernavn: 'alice', adgangskode: 'hemmelig' });
  expect(login.status).toBe(200);
  expect(login.headers['set-cookie']).toBeDefined();

  const profil = await agent.get('/profil');
  expect(profil.status).toBe(200);
  expect(profil.body.bruger.navn).toBe('alice');
});

test('session-id skifter ved login (anti-fixation)', async () => {
  const agent = request.agent(app);
  const foer = await agent.get('/taeller');
  const cookieFoer = foer.headers['set-cookie']?.[0];
  const login = await agent.post('/login')
    .send({ brugernavn: 'alice', adgangskode: 'hemmelig' });
  const cookieEfter = login.headers['set-cookie']?.[0];
  expect(cookieEfter).not.toBe(cookieFoer);
});

For at testen kan importere appen, skal du eksportere app fra app.js uden at kalde app.listen() i testmiljøet. Et almindeligt mønster er at adskille opsætning fra start: byg appen i én fil, og kald listen i en separat server.js. Så kører testene mod appen direkte, mens produktion starter serveren.

Kør suiten med npx vitest run. Forventet output bekræfter alle tre kontroller:

✓ profil afviser uden login
✓ login saetter en cookie og giver adgang
✓ session-id skifter ved login (anti-fixation)

Test Files  1 passed (1)
     Tests  3 passed (3)

Læg suiten i din CI-pipeline, så ingen ændring kan slå anti-fixation-beskyttelsen fra ubemærket. En enkelt fejlagtig refaktorering af login-ruten er nok til at åbne hullet igen, og en automatiseret test er den billigste forsikring mod netop det.

Drift og overvågning i produktion

En sikker session i Node.js slutter ikke ved koden. I drift skal du kunne se, hvad der sker med sessionerne, og reagere, når noget ser forkert ud. Det handler om tre ting: synlighed, kapacitet og beredskab.

Log de rigtige hændelser

Log login, logout, regenerering og timeout-udløb, men aldrig selve session-id’et eller adgangskoder. Et lækket logfil med session-id’er er lige så slemt som et databrud, fordi id’erne kan afspilles direkte. Log i stedet en anonymiseret reference og brugerens id:

app.post('/login', loginLimiter, (req, res) => {
  // ... validering og regenerate ...
  console.log(JSON.stringify({
    haendelse: 'login',
    bruger: req.session.bruger.navn,
    ip: req.ip,
    tid: new Date().toISOString()
  }));
  // log ALDRIG req.sessionID eller adgangskode
});

Hold øje med Redis-kapacitet

Hver aktiv session optager hukommelse i Redis. Med en TTL på 30 minutter rydder Redis selv udløbne sessions, men under et spike eller et angreb kan antallet eksplodere. Overvåg used_memory og antallet af sess:*-nøgler, og sæt en alarm, hvis det stiger unormalt hurtigt. Det kan være det første tegn på et credential stuffing-angreb.

$ redis-cli INFO keyspace
# Keyspace
db0:keys=1842,expires=1842,avg_ttl=1521000

$ redis-cli INFO memory | grep used_memory_human
used_memory_human:24.18M

Forhold mellem keys og expires bør være tæt på 1: alle session-nøgler skal have en TTL. Hvis tallene divergerer, lækker du sessions, der aldrig udløber, hvilket både er et hukommelses- og et sikkerhedsproblem.

Hav et beredskab for kompromittering

Hvis du opdager, at en angriber har fået fat i sessioner, skal du kunne ugyldiggøre alt på én gang. Med Redis er det enkelt: roter SESSION_SECRET, og ryd nøglerne med prefikset. Alle eksisterende cookies bliver ugyldige, og samtlige brugere skal logge ind igen.

# Ugyldiggoer ALLE aktive sessions i en noedsituation
$ redis-cli --scan --pattern 'sess:*' | xargs redis-cli DEL

Det er en hård, men effektiv knap. Test den i et staging-miljø, så du ved præcis, hvad der sker, før du nogensinde får brug for den i en rigtig hændelse. Et indøvet beredskab er forskellen på minutters og dages nedetid.

Almindelige faldgruber

  • Glemt session-regenerering. Uden regenerate() ved login står din app åben for session fixation, selv om alt andet er korrekt. Det er den hyppigste fejl.
  • MemoryStore i produktion. Den lækker hukommelse og mister alle sessions ved restart. Advarslen i loggen er reel, ikke kosmetisk.
  • secure-cookie uden trust proxy. Bag en TLS-terminerende proxy ser Express forbindelsen som HTTP og sætter aldrig cookien. Resultatet: brugeren kan ikke logge ind, og der er ingen fejl.
  • Domain-attribut sammen med __Host-. Browseren afviser cookien lydløst. Drop Domain helt for host-only cookies.
  • Cookie og store-TTL ude af sync. Hvis cookien lever længere end Redis-nøglen, peger en gyldig cookie på ingenting, og brugeren oplever tilfældige logouts.
  • Hemmelighed i kildekoden. En secret i Git er kompromitteret for altid. Brug miljøvariabler og roter ved mistanke.

Fejlfinding

SymptomSandsynlig årsagLøsning
Cookie sættes ikke i produktiontrust proxy manglerSæt app.set('trust proxy', 1)
Bruger logges ud ved hvert requestNy session hver gangTjek at cookien faktisk sendes; kontroller sameSite
“connect ECONNREFUSED” til RedisRedis kører ikkeStart Redis; tjek REDIS_URL
Sessions forsvinder ved restartMemoryStore i brugSkift til connect-redis
__Host-sid afvises i browserenMangler Secure eller har DomainSæt secure: true, fjern Domain
Cookie virker ikke på localhostsecure: true uden HTTPSBrug secure kun i produktion
Tæller stiger ikkesaveUninitialized: false + ingen skrivningSkriv til req.session før forventet persistens
Race ved loginSvar sendt før save()Kald req.session.save() eksplicit
Rate limit rammer alle bag proxyForkert klient-IPKonfigurer trust proxy så IP læses korrekt

Avancerede tips

Bind sessionen til kontekst. Gem en hash af brugerens User-Agent ved login, og afvis requests, hvor den ikke matcher. Det gør en stjålet cookie sværere at bruge fra en anden maskine. Vær varsom med at binde til IP, da mobilbrugere skifter netværk ofte.

Regenerér også ved privilegieskift. Når en bruger skifter rolle eller hæver sine rettigheder, så regenerér id’et igen. Det følger samme princip som login og lukker en mindre kendt fixation-vej.

Overvåg aktive sessions. Med Redis kan du liste alle sess:*-nøgler og bygge en “log ud overalt”-funktion, der sletter alle en brugers sessions på én gang. Det er uvurderligt efter et databrud. Læs hvordan brud opstår i vores guide til datalæk og beskyttelse.

Tænk på compliance. For mange danske og nordiske virksomheder er sikker sessionshåndtering en del af NIS2-kravene til adgangskontrol. Se vores gennemgang af NIS2 i Danmark for konteksten.

Det komplette projekt

Her er hele app.js samlet. Den indeholder alle 12 trin og er klar til at køre, så længe Redis og din .env er på plads.

import 'dotenv/config';
import express from 'express';
import session from 'express-session';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { createClient } from 'redis';
import { RedisStore } from 'connect-redis';

const app = express();
app.set('trust proxy', 1);
app.use(helmet());
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.on('error', (e) => console.error('Redis-fejl:', e));
await redisClient.connect();

const redisStore = new RedisStore({ client: redisClient, prefix: 'sess:', ttl: 60 * 30 });
const COOKIE_NAVN = process.env.NODE_ENV === 'production' ? '__Host-sid' : 'sid';

app.use(session({
  store: redisStore,
  name: COOKIE_NAVN,
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  rolling: true,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 1000 * 60 * 30
  }
}));

const IDLE_MS = 1000 * 60 * 30;
const ABSOLUT_MS = 1000 * 60 * 60 * 8;

function tjekTimeout(req, res, next) {
  if (!req.session.bruger) return next();
  const naa = Date.now();
  const inaktiv = naa - (req.session.sidsteAktivitet || naa);
  const alder = naa - (req.session.loginTid || naa);
  if (inaktiv > IDLE_MS || alder > ABSOLUT_MS) {
    return req.session.destroy(() => {
      res.clearCookie(COOKIE_NAVN, { path: '/' });
      res.status(440).json({ fejl: 'Session udloebet' });
    });
  }
  req.session.sidsteAktivitet = naa;
  next();
}
app.use(tjekTimeout);

function kraevLogin(req, res, next) {
  if (!req.session.bruger) return res.status(401).json({ fejl: 'Login kraevet' });
  next();
}

const loginLimiter = rateLimit({ windowMs: 1000 * 60 * 15, max: 10, standardHeaders: true, legacyHeaders: false });

app.post('/login', loginLimiter, (req, res) => {
  const { brugernavn, adgangskode } = req.body;
  const erGyldig = brugernavn === 'alice' && adgangskode === 'hemmelig';
  if (!erGyldig) return res.status(401).json({ fejl: 'Forkert login' });
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ fejl: 'Session-fejl' });
    req.session.bruger = { navn: brugernavn, rolle: 'user' };
    req.session.loginTid = Date.now();
    req.session.sidsteAktivitet = Date.now();
    req.session.save((err) => {
      if (err) return res.status(500).json({ fejl: 'Kunne ikke gemme' });
      res.json({ ok: true, bruger: req.session.bruger });
    });
  });
});

app.get('/profil', kraevLogin, (req, res) => res.json({ bruger: req.session.bruger }));

app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ fejl: 'Kunne ikke logge ud' });
    res.clearCookie(COOKIE_NAVN, { path: '/' });
    res.json({ ok: true });
  });
});

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Lytter paa http://localhost:${port}`));

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å plads. For kryptografisk baggrund om, hvordan tilfældige id’er og signaturer fungerer, kan du læse vores tutorial om Ed25519-signaturer i Node.js.

Relateret indhold

Eksterne kilder

Ofte stillede spørgsmål

Skal jeg bruge sessions eller JWT til login?

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ært med stateless JWT. Sessions med httpOnly-cookies undgår også, at tokens ligger tilgængelige for JavaScript.

Hvorfor er MemoryStore ikke god nok?

MemoryStore gemmer sessions i Node.js-processens hukommelse. Den lækker hukommelse over tid, mister alt ved en genstart og kan ikke deles mellem flere instanser. express-session advarer selv om, at den kun er beregnet til udvikling. Brug Redis eller en anden ekstern store i produktion.

Hvad er forskellen på idle- og absolut timeout?

Idle-timeout logger brugeren ud efter en periode uden aktivitet og fornys hver gang, brugeren gør noget. Absolut timeout sætter et hårdt loft på den samlede sessionslevetid og kan ikke fornys. Sammen begrænser de, hvor længe en stjålet cookie kan misbruges.

Hvorfor skal jeg regenerere session-id ved login?

Hvis du ikke skifter id ved login, kan en angriber, der på forhånd har fikseret et kendt id i offerets browser, dele den autentificerede session. req.session.regenerate() udsteder et nyt id og gør det gamle ugyldigt. Det er det enkleste og vigtigste forsvar mod session fixation.

Hvilken SameSite-værdi bør jeg vælge?

Lax er en god standard for de fleste apps: cookien sendes ved almindelig navigation, men ikke ved de fleste cross-site requests. Brug Strict til meget følsomme apps som admin-paneler. None kræver Secure og bør kun bruges, hvis du bevidst har brug for cross-site cookies.

Hvad gør __Host- prefikset?

Et cookienavn med __Host- tvinger browseren til at kræve Secure, forbyde et Domain-attribut og kræve Path=/. Cookien bliver host-only og kan ikke sættes eller overskrives fra et subdomæne, hvilket beskytter mod cookie-injektion på tværs af subdomæner.

Virker denne opsætning bag en load balancer?

Ja, så længe du bruger en delt store som Redis og sætter app.set('trust proxy', 1). Redis deler sessionerne mellem alle instanser, og trust proxy sikrer, at secure-cookies og klient-IP’er læses korrekt gennem proxyen.