Du kender det her flow:
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?
Når brugeren trykker F5 på /profile, sker der to ting i rækkefølge:
Den første og vigtigste fix: Fortæl din app, at den skal opføre sig anderledes på serveren end i browseren.
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;
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'));
// ...
}
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
}
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 }
];
Du har fikset login-loopet. Men nu opdager du noget nyt:
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.
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.
// 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.
Custom headers er aldrig forbidden. Send cookien via super-SSR-Cookie og lad Express kopiere den ind:
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);
};
// 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:
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.
Hvis det stadig ikke virker, er her tre ting du kan tjekke:
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)
const cookies = ssrRequest?.headers?.get?.('cookie');
console.log('Cookies:', cookies ? 'found' : 'missing');
// Hvis "missing": tjek at din reverse proxy forwarder Cookie-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
SSR + httpOnly cookies har tre separate problemer der alle giver samme symptom (bruger logges ud ved refresh):