Security in a Nuxt SSR App — Following One Write Through the Boundaries
Hero image credit: Photo by Chris F on Pexels
Part 15 of the Nuxt and .NET series.
Moving code to the server feels like a security improvement, and in one narrow sense it is: credentials and data access can stay out of the browser. It does nothing for the more important question, which is whether an operation a browser asked for should happen at all.
This article follows one such operation through every boundary it crosses. The examples are from the fictional document application, and the article is written as defensive design guidance — a recommendation here is not a claim that a particular system already implements it.
Table of Contents
- One Button One Write
- Where the Boundaries Actually Are
- Who Is This Request From
- Did the User Intend It
- Is the User Allowed to Do It
- Is the Payload What It Claims to Be
- What Ends Up in the Page
- Content Security Policy
- Security Policy Across Environments
- Diagnostics Endpoints and Secrets
- A Review You Can Actually Run
- What’s Next
One Button One Write
On the document detail page there is a button: Restore this revision. Clicking it makes revision 5 the current revision of document 42 — a real state change, visible to other users, with an audit trail somebody will read later.
The click goes to a server route in the Nuxt application, which calls the .NET API, which performs the write. Five questions have to be answered somewhere along that path, and the interesting part of this article is that none of them is answered by the fact that the code runs on the server:
- who is this request from?
- did that person intend to send it?
- are they allowed to do this to this document?
- is the payload what it claims to be?
- what did the response and the page leak?
Where the Boundaries Actually Are
A trust boundary is any place where data or a request crosses from something you do not control into something you do.
Three of those edges are routinely mistaken for trusted.
The browser, obviously — but this includes the request’s cookies, headers, body, query string and referrer, all of which are attacker-controlled in the case that matters.
The content service. It is a vendor you pay, not a boundary you control. Content authored by an editor, or injected via a compromised integration, is data that ends up in your HTML, so it is subject to the same encoding and sanitization discipline as user input.
Forwarded headers. Host and protocol information supplied by a proxy is only meaningful if it arrived through a path you control. An application that trusts X-Forwarded-* headers from arbitrary clients can be told it is running on another host, which turns into poisoned absolute URLs, redirect targets and cache keys. Accept those headers only from the known ingress.
Who Is This Request From
Identity comes from a session established by a server-side authorization-code flow, which is where two terms deserve separating because mixing them up produces real vulnerabilities. OAuth 2.0 authorizes access to a resource; OpenID Connect authenticates a user. An access token proves that something was granted, not who is sitting at the keyboard. If sign-in is the goal, the ID token and its validation — issuer, audience, signature, nonce — are what establishes identity.
Running the exchange on the server is what keeps a confidential client credential out of the browser, and state, PKCE and nonce validation are what keep the flow itself from being replayed or injected. The session cookie should be Secure, HttpOnly, given an appropriate SameSite policy, bounded in lifetime and rotated.
One frequent misreading: HttpOnly stops JavaScript from reading the cookie. It does not stop malicious script running in your page from making authenticated requests with it — the browser attaches it regardless. HttpOnly limits exfiltration, not abuse.
And a boundary that surprises people: protecting HTML routes does not protect static JavaScript. Bundles are usually fetchable without a session, so a bundle must contain no secrets regardless of which routes require sign-in. “Only logged-in users can reach the page that uses it” is not a control.
Did the User Intend It
The session answers who, not whether they meant to. Because the browser attaches the session cookie to requests initiated by other sites too, a state-changing endpoint authenticated only by a cookie is vulnerable to cross-site request forgery: a page elsewhere causes the visitor’s browser to restore a revision.
The defence is a token the other site cannot obtain or predict, validated on the server together with an origin check — a framework-supported synchronizer token, or a correctly signed double-submit construction, plus SameSite cookie attributes as a supporting layer rather than the whole answer.
Two precise points, because CSRF is an area where plausible-sounding designs fail:
- The token must be bound to the session or signed. Encrypting a value does not establish that the sender is authorized to submit it; a value that merely decrypts successfully is a value an attacker may have obtained from their own session.
- User-Agent matching is not protection. User-agent strings are neither secret nor stable: they can be copied trivially and change legitimately when a browser updates. Treating them as evidence of token ownership is at best an inconvenience to an attacker and at worst a false sense of having solved the problem.
Also worth stating: an XSS vulnerability defeats CSRF protection, because script inside your page can read the token. These controls only work together, which is why the CSP section below is not a separate topic.
For calls the server makes on its own behalf, use an explicit internal authentication mechanism with a narrow audience and scope. What must not exist is a general-purpose “SSR bypass” credential that skips browser-facing protections — once such a thing exists, every future endpoint is one copy-paste away from being unprotected. The in-process gateway execution from Part 2 avoids a loopback HTTP call, and it still needs the caller’s authorization context to travel with it.
Is the User Allowed to Do It
Authentication says who; authorization says whether this person may restore revisions of this document. That decision belongs on the server operation and the resource — in the .NET API, next to the write — and not in any of the following places, all of which are commonly mistaken for it:
- the absence of a button in the UI, which is a hint, not a control;
- the route requiring a session, which distinguishes “someone” from “nobody”;
- a claim in a token that the client could have influenced;
- a Part 11 assignment cookie or any other client-controlled value.
The gateway can also enforce field-level authorization, as Part 3 noted, and that is a useful layer. It is not a substitute for the resource owner checking, because the same write may be reachable by another path tomorrow.
Is the Payload What It Claims to Be
Generated types are a build-time contract. At runtime they are gone, so the body of the restore request is an arbitrary JSON document until something validates it — and Part 5’s point bears repeating here because it is the most attractive false conclusion in this series: a generated type enforces nothing at runtime.
Validate on the server: shape, types, ranges, identifiers, and whether the referenced revision belongs to the referenced document. That last one is an authorization check wearing a validation costume, and it is the one that catches an attacker substituting an id.
The same applies inbound from dependencies. A response from the content service or the API is data crossing a boundary; if it lands in HTML, it needs contextual encoding, and if it lands in a redirect or a URL, it needs validating. Rich text from a CMS is the highest-risk case, because it is designed to become markup.
What Ends Up in the Page
SSR introduces a disclosure channel that a client-rendered application does not have: the payload. Everything the server put into transferred state is serialized into the HTML, and anyone can read it. So is everything in runtimeConfig.public, per Part 6.
Two rules follow. Put only browser-intended values in public configuration — and treat a secret that has been there once and deployed as disclosed, because removing it later does not retract it. And be deliberate about what data fetching places in transferred state: a query that selected internal fields for a server-side decision publishes them, which is how internal user attributes or pricing logic end up in view-source.
Content Security Policy
CSP is the layer that limits what an injected script can do, which makes it the companion to the CSRF discussion above.
Keeping the policy in the CMS so it can change without a deployment is convenient and makes policy editing a privileged operation — it needs restricted permissions, validation, review of broad sources, and an audit trail, because an editor who can add a script source can disable the protection.
Three qualifications. A fallback policy is not automatically stricter than the one it replaces — define the intended relationship and test what happens when the CMS is unreachable. Use nonces or hashes for scripts that genuinely need to be inline, rather than weakening the whole policy to accommodate one loader, which is a temptation the deferral techniques in Part 14 create. And remember that CSP is enforced by the browser: assembling the header server-side does not mean the protection acts before code reaches the client. It is one layer alongside encoding, sanitization, dependency control and authorization.
Security Policy Across Environments
Ambiguity about authentication modes causes real gaps, so name them:
| Mode | What it means |
|---|---|
| Mandatory sign-in | Anonymous access is refused wherever the policy applies |
| Optional sign-in | Public access is allowed, and authenticated behaviour also exists |
| Development bypass | A deliberately limited local configuration, not a deployment mode |
A public site with optional sign-in still has authentication and authorization — for the operations that need them. That is different from having none, and conflating the two is how a public application ends up with an unauthenticated write.
The harder discipline is not relaxing protections because an environment is labelled non-production. Preview and integration environments hold real-looking data, talk to shared dependencies, and are reachable from the internet more often than anyone intends; they need access restriction of their own, and the production-equivalent security behaviour should be tested before release rather than after.
Diagnostics Endpoints and Secrets
Operational endpoints are the ones most likely to be left open, because they are added while debugging.
Keep a liveness probe minimal and separate from operator diagnostics. Worker metrics, log-level controls, profiling and heap capture need real access control — and two common substitutes are not access control: a probe user-agent string is trivially spoofable, and returning 404 avoids advertising an endpoint without preventing anyone from reaching it.
Heap snapshots deserve a specific warning, because they are the exception to careful logging: a snapshot contains whatever was in memory, including tokens and request bodies, no matter how well the logger redacts. Treat captures as sensitive artefacts with bounded retention and restricted export, and give logs the same policy — redact bodies and credentials, bound retention, restrict who can export.
For secrets themselves, use managed identities and secret references where the platform supports them, and keep resolved values out of logs, bundles, screenshots and examples. A secret reference protects configuration storage; it cannot stop application code from logging the value it resolved to. And validate upstream TLS: if an internal service uses a private CA, configure that trust chain rather than globally disabling verification, which is a one-line change that removes transport security everywhere.
A Review You Can Actually Run
Back to the button. The trustworthy version of “restore this revision” has answers at each boundary: a session established by a validated server-side flow, a CSRF token bound to that session plus an origin check, an authorization decision made by the API against this document and this user, server-side validation that the revision belongs to the document, a response that reveals nothing about what the user may not see, and a payload that carries no server-only data.
The transferable part is the shape of that list. For any browser-triggered operation that changes state, write the five answers down:
- Identity — what established it, and what would happen if the cookie were forged?
- Intent — what would block the same request issued from another site?
- Authorization — which component decides, and does it check the specific resource?
- Input — what validates the payload at runtime, given that types do not?
- Output — what does the response, and the page’s payload, disclose?
If any answer is “it runs on the server,” that boundary is unguarded. Server execution changes where code lives; it does not decide whether an operation should happen.
What’s Next
- Part 16: Configuration Generation and Safe Releases — Getting settings and secret references into several environments without divergence.
- Part 17: Memory, Stability, and PM2 — Keeping the process that enforces all of this alive.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net