·

Security in a Nuxt SSR App — CSRF, OAuth, CSP, and More

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:

EnvironmentCSRFAuthCSPRationale
DevelopmentOffOffOffFast iteration, minimal friction
DockerOffOnOffProtects shared dev environments
TestOnOnOnProduction-like security
ProductionOnOffOnPublic 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

Your email address will not be published. Required fields are marked *