Fifteenth in a series about migrating from legacy architectures to a modern Nuxt 4 stack.
Security in SSR Is Different
An SSR application has a very different attack surface from a client-side SPA. The server is responsible for rendering HTML with embedded state, generating tokens, setting cookies, and proxying API calls — all before the browser executes any JavaScript.
Security must be enforced at the server rendering layer. A CSRF token created during SSR has to survive hydration. Authentication must block the HTTP response before it ever reaches the browser. CSP must be sent as an HTTP header during rendering, not injected later as a meta tag.
Reusing SPA security patterns directly in SSR apps creates gaps — not because the patterns are wrong, but because they operate at the wrong layer.
flowchart LR
subgraph Client["Browser"]
HTML["SSR HTML + Embedded State"]
JS["Hydrated JS App"]
end
subgraph Server["Nuxt SSR Stack"]
Render["SSR Render Layer"]
Tokens["Token Generation<br/>(CSRF, Auth)"]
Cookies["Set Cookies<br/>(HTTP-only, SameSite)"]
Proxy["API Proxy / BFF"]
end
Client <-- "HTTP Response" --> Server
Render --> HTML
Render --> Tokens
Tokens --> Cookies
Render --> Proxy
Proxy -->|"Internal API Calls"| Backend["Upstream APIs / Services"]
CSRF Protection: Dual-Token System with User-Agent Binding
CSRF protection in an SSR app needs more than the classic double-submit cookie pattern.
The Standard Pattern (And Why It’s Not Enough)
The traditional double-submit approach: generate a token, store it in a cookie, and require it in a request header. The server verifies that the cookie and header values match. This works because a cross-site attacker cannot read the cookie in order to set the matching header.
The weakness: if an attacker somehow gets both the cookie and the token (for example, via a subdomain cookie issue or XSS on a related domain), they can replay the request from any browser.
The Enhanced Pattern: User-Agent Binding
The fix is to bind the token to the specific browser that requested it:
flowchart TB
subgraph SSR["Token Generation During SSR"]
UA["User-Agent Header"]
Time["Current Timestamp"]
Salt["Random UUID Salt"]
Type["Token Type = 'client'"]
HashUA["SHA-256(User-Agent)<br/>→ first 16 chars"]
Payload["Token Payload<br/>d: timestamp<br/>p: type<br/>s: salt<br/>ua: UA hash"]
Enc["Encrypt with AES-256-GCM"]
UA --> HashUA
Time --> Payload
Type --> Payload
Salt --> Payload
HashUA --> Payload
Payload --> Enc
end
Enc --> Cookie["Set HTTP-only cookie: csrf"]
Enc --> Embed["Embed token in SSR HTML<br/>(for X-XSRF-TOKEN header)"]
flowchart TB
subgraph Validation["Token Validation on Every API Request"]
HeaderTok["X-XSRF-TOKEN header"]
CookieTok["csrf cookie"]
Decrypt["Decrypt header token"]
Exp["Check expiration (24h TTL)"]
ReqUA["Current User-Agent"]
HashReqUA["SHA-256(Req UA)<br/>→ first 16 chars"]
MatchUA["Compare ua in token<br/>with current UA hash"]
MatchCookie["Compare cookie value<br/>with header value"]
Ok["All checks pass"]
Reject["Reject 403 Forbidden"]
HeaderTok --> Decrypt
Decrypt --> Exp
Decrypt --> MatchUA
ReqUA --> HashReqUA --> MatchUA
Decrypt --> MatchCookie
CookieTok --> MatchCookie
Exp -->|valid| MatchUA
Exp -->|expired| Reject
MatchUA -->|mismatch| Reject
MatchUA -->|match| MatchCookie
MatchCookie -->|mismatch| Reject
MatchCookie -->|match| Ok
Ok -->|"Process API request"| App["Application Handler"]
end
A stolen token is useless from a different browser — the User-Agent hash will not match. Combined with AES-256-GCM encryption, random salts, and a 24-hour TTL, this creates layered defenses against replay attacks.
SSR Bypass Tokens
During SSR, the Nuxt server calls its own GraphQL or REST APIs — there is no browser, and therefore no CSRF cookie. A server-only bypass token allows these internal SSR requests to pass CSRF checks.
This token is generated per request, stored only in the Nitro event context (never exposed to the client), and validated using User-Agent binding but without the cookie–header comparison.
sequenceDiagram participant Browser participant NuxtSSR as Nuxt SSR Renderer participant NitroCtx as Nitro Event Context participant InternalAPI as Internal API Browser->>NuxtSSR: HTTP GET /page NuxtSSR->>NitroCtx: Create SSR bypass token<br/>(bound to UA, no cookie) Note right of NitroCtx: Token stored only in<br/>server context, not sent<br/>to the client NuxtSSR->>InternalAPI: Request with SSR bypass token<br/>(e.g. header X-SSR-CSRF) InternalAPI-->>InternalAPI: Validate token + UA<br/>(no cookie-header check) InternalAPI-->>NuxtSSR: Data response NuxtSSR-->>Browser: Rendered HTML
Encryption Key from Key Vault
The AES key is loaded from a cloud key vault (for example, Azure Key Vault or AWS KMS) at startup and stored on globalThis. If the key cannot be loaded, the server refuses to start — a fail-fast approach with no degraded mode. CSRF protection is never quietly turned off.
flowchart LR
subgraph Startup["Nuxt Server Startup"]
KV["Cloud Key Vault<br/>(AWS KMS / Azure Key Vault)"]
Fetch["Fetch AES-256 Key"]
Store["Store key on globalThis"]
Ready["Server Ready"]
Fail["Abort startup<br/>(process exit)"]
KV --> Fetch
Fetch -->|success| Store --> Ready
Fetch -->|failure| Fail
end
OAuth 2.0 Authentication (Authorization Code Flow)
Test and staging environments often require authentication, even for otherwise public-facing applications. A common pattern is server-side OAuth 2.0 Authorization Code Flow — the client secret is never exposed to the browser.
sequenceDiagram
participant Browser
participant Nuxt as Nuxt Server
participant IdP as Identity Provider
Browser->>Nuxt: GET /protected-page
Nuxt-->>Nuxt: Check auth cookie
alt No valid token
Nuxt-->>Browser: 302 Redirect to /api/auth/login
Browser->>Nuxt: GET /api/auth/login
Nuxt-->>Browser: 302 Redirect to IdP auth URL
Browser->>IdP: GET /authorize?client_id=...&redirect_uri=/api/auth/callback
IdP-->>Browser: 302 Redirect to /api/auth/callback?code=...
Browser->>Nuxt: GET /api/auth/callback?code=...
Nuxt->>IdP: POST /token (exchange code for token)
IdP-->>Nuxt: Access token
Nuxt-->>Browser: Set HTTP-only auth cookie + 302 /protected-page
Browser->>Nuxt: GET /protected-page
Nuxt-->>Browser: 200 Full HTML
else Valid token
Nuxt-->>Browser: 200 Full HTML (no redirect)
end
Key security properties:
- Server-side token exchange — the client secret is used only on the server, never sent to the browser.
- HTTP-only cookies — tokens live in cookies that JavaScript cannot read (mitigating XSS).
- Middleware blocking — unauthenticated requests are stopped in server middleware before any page content is rendered. Unauthorized users cannot even download the app’s JavaScript bundle.
Environment-Based Security Tiers
Not every environment needs the full security stack:
| Environment | CSRF | Auth | CSP | Rationale |
|---|---|---|---|---|
| Development | Off | Off | Off | Fast iteration, minimal friction |
| Docker | Off | On | Off | Protects shared dev environments |
| Test | On | On | On | Production-like security |
| Production | On | Off | On | Public site, no login required |
Development turns security off for productivity. Test enables everything to catch issues before release. Production enables CSRF and CSP but omits authentication for a public site.
flowchart LR Dev["Development"]:::off -->|Deploy| Docker["Docker / Shared Dev"]:::partial Docker -->|Promote| Test["Test / Staging"]:::full Test -->|Promote| Prod["Production"]:::prod classDef off fill:#eee,stroke:#999,color:#333; classDef partial fill:#ffe6b3,stroke:#cc9a00,color:#333; classDef full fill:#c6f6d5,stroke:#2f855a,color:#000; classDef prod fill:#bee3f8,stroke:#2b6cb0,color:#000; Dev --- DevSec["CSRF: Off<br/>Auth: Off<br/>CSP: Off"] Docker --- DockSec["CSRF: Off<br/>Auth: On<br/>CSP: Off"] Test --- TestSec["CSRF: On<br/>Auth: On<br/>CSP: On"] Prod --- ProdSec["CSRF: On<br/>Auth: Off<br/>CSP: On"]
Runtime Content Security Policy from CMS
Hardcoding CSP in application config is an operational choke point: every new script source (analytics, chat widgets, A/B testing) forces a code change and deployment.
Treating CSP as content solves this. A server plugin fetches CSP from the CMS at runtime and applies it as an HTTP header:
flowchart TB Editor["CMS Editor<br/>updates CSP entry"] --> CMS["Headless CMS"] CMS --> Cache["Server Plugin<br/>fetches CSP (cache 5 min)"] Cache --> Header["Set Content-Security-Policy<br/>header on HTML responses"] Header --> Browser["Browser enforces CSP"] CMS -.failure.-> Fallback["Use hardcoded fallback CSP<br/>(stricter, not looser)"] Fallback --> Header
A hardcoded fallback covers CMS downtime. The runtime CSP is strictly more permissive than the fallback — if the CMS fetch fails, the app operates under a stricter, not looser, policy.
Internal API Guard
Health and diagnostics endpoints expose sensitive operational data — memory usage, restart counts, worker status. The Internal API Guard keeps these endpoints invisible to the public:
sequenceDiagram
participant PublicClient as External Client
participant Probe as Kube Health Probe
participant InternalSvc as Internal Service
participant NuxtAPI as Nuxt API Layer
PublicClient->>NuxtAPI: GET /api/health/pm2<br/>User-Agent: Mozilla/5.0
NuxtAPI-->>PublicClient: 404 Not Found
Probe->>NuxtAPI: GET /api/health<br/>User-Agent: kube-probe/1.28
NuxtAPI-->>Probe: 200 OK { status: "healthy" }
InternalSvc->>NuxtAPI: GET /api/health/pm2<br/>X-Internal-Secret: correct
NuxtAPI-->>InternalSvc: 200 OK { workers: [...] }
The 404 is deliberate — it neither confirms nor denies the endpoint’s existence. Scanners see exactly what they would for any non-existent path.
The Security Stack
All layers combine into a single request pipeline:
flowchart TB
Browser["Browser"] --> HTTPS["HTTPS"]
HTTPS --> Edge["Edge Load Balancer"]
Edge -->|"TLS termination"| Nginx["Nginx Proxy"]
Nginx --> Guard["Internal API Guard<br/>(hide internal endpoints)"]
Guard --> Nuxt["Nuxt Server"]
subgraph NuxtPipeline["Nuxt Middleware & Plugins"]
Auth["Auth Middleware<br/>Check auth cookie<br/>Redirect if invalid"]
CSRF["CSRF Middleware<br/>Decrypt token<br/>Validate UA hash<br/>Check cookie-header match"]
CSP["CSP Plugin<br/>Fetch CSP from CMS<br/>Set CSP header"]
SSR["SSR Render<br/>Generate CSRF token<br/>Set HTTP-only cookie<br/>Embed token in HTML"]
end
Nuxt --> Auth --> CSRF --> CSP --> SSR
Lessons Learned
SSR security operates at the HTTP response level, not the DOM level
In a SPA, security typically lives in JavaScript — interceptors, route guards, client-side middleware. In SSR, it must live in server middleware controlling the HTTP response. By the time browser JavaScript runs, the HTML (and any injected payloads) has already been sent.
flowchart LR
subgraph SPA["SPA Model"]
JSClient["Client-side JS<br/>(route guards, interceptors)"]
API["APIs"]
JSClient --> API
end
subgraph SSR["SSR Model"]
Middleware["Server Middleware<br/>(auth, CSRF, CSP)"]
Render["SSR Render"]
API2["APIs"]
Middleware --> Render --> API2
end
User-Agent binding is cheap insurance against replay attacks
Hashing the first 16 characters of the User-Agent costs almost nothing (one SHA-256 per request) but shuts down an entire class of replay attacks. The User-Agent is available on every request — binding to it is practically free.
flowchart TB UA["User-Agent string"] --> Hash["SHA-256 + truncate 16 chars"] Hash --> Bind["Include hash in token payload"] Bind --> Verify["On request, recompute hash<br/>and compare before processing"]
Runtime security configuration reduces operational bottlenecks
Any security setting that requires a deployment to change becomes a bottleneck. CSP changes are often requested by marketing (new script providers) and security (removing outdated sources). Moving CSP to the CMS takes the development team out of this loop.
flowchart LR Marketing["Marketing / Security"] --> ChangeReq["Request CSP change"] ChangeReq --> CMSConfig["Update CSP in CMS"] CMSConfig --> AutoApply["Server auto-applies CSP<br/>on next cache refresh"] AutoApply --> LiveSite["Live Site with updated policy"]
Environment tiers prevent security theater
Full security in development forces engineers to work around it. Zero security in production is reckless. A tiered approach — each environment enabling exactly the protections it needs — balances safety with productivity.
flowchart TB Dev["Dev: Minimal security<br/>High productivity"] --> Docker["Shared Dev: Auth only"] Docker --> Test["Test: Full security<br/>Pre-prod hardening"] Test --> Prod["Prod: Public-friendly<br/>CSRF + CSP only"]
What’s Next
- Article 13: Observability and Distributed Tracing — Application Insights End-to-End — How every request is traced across all layers.
- Article 14: AI-Assisted Development — MCP, Debug Chatbot, and the Shared Language of the Codebase — Making AI assistants genuinely useful for live debugging.
- Article 15: Load Testing Results — 15× Faster, 5× More Capacity — The measured proof that architecture decisions produce real outcomes.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Leave a Reply