Din cookie er der. Din bruger er logget ind.

Men SSR kan ikke se den. Her er hele historien.

Scenariet: Alt virker, indtil det ikke gør

Du kender det her flow:

  1. Bruger logger ind — virker
  2. Bruger navigerer til /profile — virker
  3. Bruger ser siden — virker
  4. Bruger trykker F5
  5. Bruger bliver smidt til login
  6. Bruger logger ind igen
  7. F5 — smidt ud igen
  8. Bruger begynder at overveje sine livsvalg

Du åbner DevTools, kigger under Application, og der ligger din ssr cookie. Helt fin. Gyldig. httpOnly. Secure. Alle de rigtige flag.

Så hvorfor virker det ikke?

Hvad der faktisk sker under motorhjelmen

Når brugeren trykker F5 på /profile, sker der to ting i rækkefølge:

Fase 1: SSR (serveren)

  1. Browseren sender request til din server — med httpOnly cookie.
  2. Server sender request videre til Angular SSR-motoren.
  3. Angular renderer din komponent på serveren.
  4. Din (måske) AuthGuard kører og kalder restoreSession()
  5. restoreSession() laver et HTTP-kald til /api/auth/session
  6. Men dette kald har ingen cookies — fordi httpOnly cookies kun eksisterer i browseren
  7. BFF returnerer { authenticated: false } (BFF = backend-for-frontend i dette eksempel)
  8. Guard'en redirecter til login
  9. SSR returnerer HTML med redirect til /login

Fase 2: Hydration (browseren)

  1. JavaScript loader og Angular hydrerer
  2. Nu har browseren adgang til httpOnly cookien
  3. Auth ville virke her — men redirectet er allerede sket
  4. For sent
Kerneproblemet: httpOnly cookies eksisterer kun i browseren. Server-side JavaScript kan ikke læse dem. Under SSR er der ingen browser — kun Node.js.

Fix 1: Lad SSR slippe auth-beskyttede routes igennem

Den første og vigtigste fix: Fortæl din app, at den skal opføre sig anderledes på serveren end i browseren.

AuthGuard — skip på serveren

Din guard skal returnere true under SSR og lade browseren håndtere auth efter hydration:

import { inject, PLATFORM_ID } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
import { AuthFacade } from '../auth/auth.facade';

export const authGuard: CanActivateFn = async () => {
  const platformId = inject(PLATFORM_ID);
  const auth = inject(AuthFacade);
  const router = inject(Router);

  // SSR: tillad altid — browseren validerer efter hydration
  if (!isPlatformBrowser(platformId)) return true;

  // Allerede autentificeret - alt godt, videre med dig.
  if (auth.isAuthenticated()) return true;

  // Prøver at gendanne session fra httpOnly cookie
  await auth.ensureRestored();

  // Tjek igen efter restore
  if (auth.isAuthenticated()) return true;

  // Ikke logget ind — redirect til din favoritside
  router.navigate(['/']);
  return false;
};

Læg mærke til den kritiske detalje: vi tjekker isAuthenticated() igen efter ensureRestored(). Uden det andet tjek redirecter guarden altid, selvom sessionen blev gendannet.

// FORKERT — tjekker ikke resultatet af restore
await auth.ensureRestored();
router.navigate(['/']);  // kører ALTID
return false;

AuthFacade — skip restoreSession på serveren

Din session-restore metode skal også vide, at den kører på serveren:

async restoreSession(): Promise<void> {
  // httpOnly cookies er ikke tilgængelige under SSR
  if (!isPlatformBrowser(this.platformId)) return; // <- skippe di dup.
   
// Du håndterer sikkert auth/session/user anderledes end her, juster efter behov try { const me = await firstValueFrom( this.api.get('auth/session') ); if (me.authenticated && me.user) { this.userSignal.set(me.user); this.isAuthenticated.set(true); } } catch { this.userSignal.set(null); this.isAuthenticated.set(false); } }
// FORKERT — laver HTTP-kald under SSR, som altid fejler
async restoreSession(): Promise<void> {
  // if (!isPlatformBrowser(this.platformId)) return;  <-- udkommenteret!
  const me = await firstValueFrom(this.api.get('auth/me'));
  // ...
}

APP_INITIALIZER — kontrolleret auth ved app-start

Registrer en APP_INITIALIZER der skipper auth under SSR:

import { APP_INITIALIZER, Injector, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

export function initializeAuth(
  authService: AuthFacade,
  injector: Injector,
  platformId: object
) {
  return () => {
    if (!isPlatformBrowser(platformId)) {
      return Promise.resolve(); // SSR — skip
    }
    return authService.ensureRestored(); // Browser — tjek cookie
  };
}

// I providers:
{
  provide: APP_INITIALIZER,
  useFactory: initializeAuth,
  deps: [AuthFacade, Injector, PLATFORM_ID],
  multi: true
}

Server Routes — brug RenderMode.Client for auth-sider

Auth-beskyttede sider (private) har ingen SEO-værdi og kan alligevel ikke renderes korrekt under SSR. Brug RenderMode.Client for dem:

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  // Offentlige sider — SSR for SEO
  { path: '', renderMode: RenderMode.Server },
  { path: 'items', renderMode: RenderMode.Server },
  { path: 'items/:slug', renderMode: RenderMode.Server },

  // Auth-beskyttede sider — skip SSR
  { path: 'super-secret-site', renderMode: RenderMode.Client },
  { path: 'items/create', renderMode: RenderMode.Client },
  { path: 'items/edit/:slug', renderMode: RenderMode.Client },

  { path: '**', renderMode: RenderMode.Server }
];
Med disse fire fixes stopper login-loopet. Brugeren kan refreshe på beskyttede sider uden at blive smidt ud. Men vi skal længere ind... vi skal helt ind i sindet...

Fix 2: Brugerspecifik data mangler efter refresh

Du har fikset login-loopet. Men nu opdager du noget nyt:

  1. Bruger åbner en side med sit yndlingsitem som han har trykket LIKE på — fin gul stjerne
  2. Bruger trykker F5.
  3. Siden loader — men hans fine gule stjerne er væk?! HVOR er mit LIKE?
  4. Bruger navigerer væk og tilbage — nu er de der igen, hva' dælen.

Hvad sker der? Siden /items/:slug er RenderMode.Server (for SEO). Under SSR kalder din komponent GET /api/items/666 til din BFF (eller din Express etc.). Men BFF ser ingen auth-cookie — så den sender data til backend uden authorization header. Backend returnerer det flotte item, men uden information omkring dit LIKE.

Problemet er det samme som før: SSR-serverens HTTP-kald har ingen cookies. Men denne gang kan vi ikke bare skippe kaldet — vi har brug for item specifik data til SEO. Vi har bare også brug for brugerens data.

Løsningen: Forward cookies fra browser-request til SSR-kald

Angular 20+ giver os adgang til det originale browser-request via REQUEST injection token. Vi kan læse cookien derfra og sende den med på interne API-kald.

Men der er en farlig lille Gumbas fælde.

Forsøg 1: Sæt Cookie-headeren (virker ikke)

// VIRKER IKKE — Cookie er en "forbidden header" i Fetch API
req.clone({ setHeaders: { Cookie: cookies } })

Node.js bruger undici som HTTP-klient. Undici følger Fetch-specifikationen, som definerer Cookie som en "forbidden header name". Headeren bliver stille og roligt fjernet. Ingen fejl. Ingen warning. Den er bare væk.

Du kan se det i dine logs: interceptor siger "cookies forwarded", men Express-handleren siger "cookie header: false".

Kort fortalt: fetch() laver ballade.

Forsøg 2: Brug en custom header (virker)

Custom headers er aldrig forbidden. Send cookien via super-SSR-Cookie og lad Express kopiere den ind:

Angular interceptor — sender cookies via custom header

import { HttpInterceptorFn } from '@angular/common/http';
import { inject, PLATFORM_ID, REQUEST } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

export const ssrCookieInterceptor: HttpInterceptorFn = (req, next) => {
  const platformId = inject(PLATFORM_ID);

  // Browseren håndterer cookies automatisk
  if (isPlatformBrowser(platformId)) {
    return next(req);
  }

  // Hent det originale browser-request fra Angular DI
  const ssrRequest = inject(REQUEST, { optional: true }) as Request | null;
  if (!ssrRequest) return next(req);

  // Læs cookies (Angular 20+ giver en Web Request)
  const cookies = ssrRequest.headers?.get?.('cookie');
  if (!cookies) return next(req);

  // Matcher /api/ i både relative og fulde URLs
  const isApiCall = /\/api\//.test(req.url);

  if (isApiCall) {
    // Custom header — Cookie er forbidden i Node's fetch
    return next(
      req.clone({ setHeaders: { 'super-SSR-Cookie': cookies } })
    );
  }

  return next(req);
};
Bemærk URL-checket bruger regex /\/api\// i stedet for startsWith('/api/'). Under SSR resolver Angular relative URLs til fulde URLs som https://ditdomæne.dk/api/items/686... (startsWith ville aldrig matche).

Express middleware — modtager og sikrer custom headeren

// Placer FØRST i din Express middleware-kæde, før alle routes
app.use((req, _res, next) => {
// lidt sikkerhed - trust me const isLoopback = req.socket.remoteAddress === '127.0.0.1' || req.socket.remoteAddress === '::1' || req.socket.remoteAddress === '::ffff:127.0.0.1'; if (isLoopback && !req.headers.cookie && req.headers['super-ssr-cookie']) { req.headers.cookie = req.headers['super-ssr-cookie'] as string; } // Fjern ALTID custom headeren — den må aldrig lække videre ned i systemet delete req.headers['super-ssr-cookie']; next(); });

Tre sikkerheds-anbefalinger:

  1. Loopback-only — accepterer kun headeren fra 127.0.0.1, ::1 og ::ffff:127.0.0.1. En ekstern lille Gumbas kan ikke spoofe den.
  2. Kun som fallback — !req.headers.cookie sikrer at en ægte browser-cookie aldrig overskrives.
  3. Altid cleanup — delete req.headers['super-ssr-cookie'] fjerner headeren uanset hvad, så den aldrig sendes videre til backend.

Registrer interceptoren i app.config.ts

import { ssrCookieInterceptor } from '../shared/ssr-cookie-interceptor';
import { authInterceptor } from '../shared/http-interceptor';

provideHttpClient(
  withFetch(),
  withInterceptors([ssrCookieInterceptor, authInterceptor])
)

ssrCookieInterceptor skal komme før authInterceptor i arrayet, så cookies er sat inden auth-logik kører.

Det samlede flow efter alle fixes

Refresh på en offentlig side (/items/smukkeste-item-666)

  1. Browser sender request med httpOnly cookie til Express
  2. Express sender til Angular SSR
  3. Komponent kalder GET /api/items/666
  4. ssrCookieInterceptor læser cookie fra browser-requestet og sætter super-SSR-Cookie
  5. Express middleware kopierer super-SSR-Cookie til req.headers.cookie
  6. BFF (eller Express etc.) læser cookie, sender auth token til backend
  7. Backend returnerer item med dine brugerens personlige likes osv.
  8. SSR renderer siden med brugerens likes og ratings osv. synlige

Refresh på en beskyttet side (/super-secret-site)

  1. Serveren sender en tom HTML-shell (RenderMode.Client)
  2. Browser bootstrapper Angular
  3. APP_INITIALIZER kalder ensureRestored() med httpOnly cookie
  4. Auth lykkes, authGuard returnerer true
  5. Siden vises

Debugging-tips

Hvis det stadig ikke virker, er her tre ting du kan tjekke:

1. Er REQUEST token tilgængelig?

const ssrRequest = inject(REQUEST, { optional: true });
console.log('REQUEST present:', !!ssrRequest);
console.log('Type:', ssrRequest?.constructor?.name);
// Forventet: "Request" (Web Request API i Angular 20)

2. Kan du læse cookies fra REQUEST?

const cookies = ssrRequest?.headers?.get?.('cookie');
console.log('Cookies:', cookies ? 'found' : 'missing');
// Hvis "missing": tjek at din reverse proxy forwarder Cookie-headeren

3. Modtager Express headeren?

// I din Express route handler
console.log('cookie:', !!req.headers.cookie);
console.log('super-ssr-cookie:', !!req.headers['super-ssr-cookie']);
// Hvis begge er false: interceptoren kører ikke, eller URL-matchet fejler
Det mest lumske lille drilagtige problem: req.url.startsWith('/api/') er altid false under SSR, fordi Angular resolver relative URLs til fulde URLs. Brug /\/api\//.test(req.url) i stedet.

Tjekliste

  • restoreSession() skipper HTTP-kald under SSR med isPlatformBrowser()
  • authGuard returnerer true under SSR
  • authGuard tjekker isAuthenticated() igen efter ensureRestored()
  • APP_INITIALIZER er registreret og skipper under SSR
  • Auth-beskyttede routes bruger RenderMode.Client
  • ssrCookieInterceptor er registreret før authInterceptor
  • Interceptor bruger super-SSR-Cookie (ikke Cookie)
  • Interceptor matcher URL med regex (ikke startsWith)
  • Express middleware accepterer kun super-SSR-Cookie fra loopback
  • Express middleware sletter altid super-SSR-Cookie efter brug

OPSUMMERING

SSR + httpOnly cookies har tre separate problemer der alle giver samme symptom (bruger logges ud ved refresh):

  1. Auth-logik kører under SSR hvor cookies ikke er tilgængelige — fix med isPlatformBrowser guards og RenderMode.Client.
  2. Brugerspecifik data mangler fordi SSR-kald ikke har cookies — fix med en interceptor der forwarder cookies fra det originale browser-request.
  3. Node's fetch stripper Cookie-headeren (forbidden header) — fix med en custom super-SSR-Cookie header og Express middleware.
Som næste skridt: Overvej at implementere TransferState så data hentet under SSR ikke hentes igen under hydration. Og tilføj monitoring (f.eks. Sentry) på SSR-fejl, så du opdager auth-problemer før dine brugere gør.

Kommentar / Feedback