Security in a Nuxt SSR App — Following One Write Through the Boundaries
Photo by Chris F on Pexels
Moving code to the server feels like a security improvement, and in one narrow sense it genuinely is: credentials and data access can stay out of the browser entirely. What it does nothing at all for is the more important question, which is whether an operation a browser asked for should happen in the first place.
So this article follows one such operation through every boundary it crosses. The examples come from the fictional document application, and I’ve written this as defensive design guidance. A recommendation here isn’t a claim that any 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 our document detail page there’s a button: Restore this revision. Clicking it makes revision 5 the current revision of document 42. That’s a real state change, visible to other users, with an audit trail somebody is going to read later on.
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 whole article is that not one 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 don’t control into something you do.
Three of those edges get routinely mistaken for trusted ones.
The browser, obviously. But that includes the request’s cookies, headers, body, query string and referrer, every one of which is attacker-controlled in the case that actually matters.
The content service. It’s a vendor you pay, not a boundary you control. Content authored by an editor, or injected through a compromised integration, is data that ends up in your HTML, so it’s subject to exactly the same encoding and sanitization discipline as user input.
Forwarded headers. A reverse proxy tells the application which host and protocol the visitor originally used by adding headers like X-Forwarded-Host and X-Forwarded-Proto, because the proxy-to-application connection no longer carries that information itself. The application needs those values in order to build absolute URLs. The catch is that they’re just request headers, so a client can send them too, and if the application believes them then it can be told it’s running on a host the attacker owns.
And the consequences are very concrete. A password-reset or sign-in-callback link built from the forwarded host now points at attacker.example instead of at your domain, so your visitor hands their token straight to the attacker. A cached page keyed partly by host can be stored under your hostname with attacker-chosen absolute URLs inside it, and then served to the next visitor along. The fix is a configuration decision rather than code: accept X-Forwarded-* only when the request arrived from the known ingress address, and ignore the headers otherwise.
Who Is This Request From
Identity comes from a session the Nuxt server establishes through an authorization-code flow: the browser gets sent to the identity provider, the user signs in there, the provider redirects back with a short-lived code, and the server trades that code for tokens. Before we look at how the flow is protected, two terms deserve separating, because mixing them up produces real vulnerabilities. OAuth 2.0 authorizes access to a resource; OpenID Connect authenticates a user.
The practical difference lies in what each token tells you. An access token is a bearer credential: it says “the holder of this string may call that API with these scopes”. It says nothing whatsoever about who’s sitting at the keyboard, and it may well have been issued to a background job. OpenID Connect adds a second token, the ID token, which is a signed statement from the provider about the user: subject identifier, when they authenticated, and usually name and email. That’s the one you use to establish identity for a session.
And because an ID token is only a signed string travelling in a redirect, it’s worth being explicit about what validating it actually involves. Four checks carry all the weight:
- Signature. Verify the token was signed with the provider’s current public key, fetched from its published key set. Without this, anybody can hand you a token they wrote themselves.
- Issuer (
iss). Check that it names your provider. A token signed by some other perfectly legitimate provider is still a valid token; it’s just a statement about a user in somebody else’s directory. - Audience (
aud). Check that it names your client. Providers issue tokens for lots of applications, and a token minted for a different application must not be accepted as a sign-in to yours. - Nonce. Check that it matches the one-time value your server generated for this sign-in attempt, which I explain in more detail below.
Skip any one of those and “the user is signed in” becomes “somebody presented us with a string”.
Two things are going on in that diagram, and they protect against quite different problems.
The first is where the code-for-token exchange runs. Trading the authorization code for tokens needs the application’s client secret. Doing that on the Nuxt server means the secret lives in server-side configuration. Doing it in browser JavaScript would mean shipping it to every visitor, where anybody can read it out of the bundle and impersonate your application at the provider.
The second is the three values the flow carries along with it. Each one closes off a specific attack, and I find them much easier to remember by the attack than by the name:
state, against somebody else’s sign-in finishing in your browser. An attacker starts a sign-in, gets an authorization code back, and then tricks you into visiting the callback URL with their code attached. Your server exchanges it, and you’re now silently logged in as the attacker, which matters rather a lot if you then go and save a document or a payment method into their account.stateis a random value your server puts into the outgoing request, remembers for that browser, and compares on the way back. A callback the user’s own browser never initiated has no matchingstateand gets rejected.- PKCE, against a stolen code being redeemed by somebody else. The authorization code travels through the browser and it can leak: a referrer header, a logged URL, a malicious app registered for the same redirect. With PKCE the server invents a secret verifier at the start, sends only its hash (the challenge) with the authorization request, and then has to present the original verifier when it redeems the code. A stolen code on its own is useless, because the thief can’t produce the verifier.
- Nonce, against an old or replayed ID token being reused. A captured ID token stays valid until it expires, so an attacker who gets hold of one could otherwise present it again to open a new session. The nonce is a one-time value your server sends with the authorization request, and the provider embeds it in the ID token it issues. A token that comes back without the value generated for this attempt is a token from some other sign-in, so it gets discarded.
“Replay” throughout means presenting a legitimate message a second time, and “injection” means slipping a legitimate message from a different sign-in into yours. Those three values are how the server tells “this is the response to the request I just made” apart from “this is a response to some request”.
Once identity has been established, the session cookie should be Secure, HttpOnly, given an appropriate SameSite policy, bounded in lifetime, and rotated.
One misreading I run into often: HttpOnly stops JavaScript from reading the cookie. It doesn’t stop malicious script running inside your page from making authenticated requests with it, because the browser attaches it regardless. HttpOnly limits exfiltration, not abuse.
And here’s a boundary that surprises people: protecting your HTML routes does not protect your static JavaScript. Bundles are usually fetchable with no session at all, so a bundle must contain no secrets whatsoever, 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 it. And because the browser attaches the session cookie to requests initiated by other sites as well, a state-changing endpoint authenticated only by a cookie is wide open to cross-site request forgery: some page elsewhere gets the visitor’s browser to restore a revision.
Picture it concretely. The visitor is signed in to the document application in one tab, and in another tab they open a page containing a hidden form that posts to your restore endpoint. The browser sends the request with the session cookie attached, because that’s what cookies do, and your server sees a perfectly authenticated request to restore revision 5. The attacker never sees the response, and doesn’t need to, because the damage is the write itself.
The defence is to require something on the request that the other site can’t obtain or predict. In practice that means a token plus a check of where the request came from:
- A synchronizer token is a random value the server generates for the session, embeds in the page, and expects back on the request. The attacker’s page can’t read your page’s HTML, because the browser’s same-origin policy prevents it, so it can’t supply the value.
- A double-submit pattern sends the same value in both a cookie and a header or form field, and the server checks the two match. It saves you server-side storage, but it only works if the value is signed or otherwise tied to the session, for the reason in the next paragraph.
- An origin check compares the
OriginorRefererheader against your own host and rejects requests the browser says came from somewhere else. SameSitecookie attributes stop the cookie being attached to many cross-site requests at all. Treat that as a helpful additional layer rather than the whole answer, because the behaviour varies across browsers and request types, and it won’t protect you against a request from a subdomain you don’t control.
Two precise points, because CSRF is an area where thoroughly plausible-sounding designs fail:
- The token has to be bound to the session, or signed. It’s tempting to think an encrypted token is safe because an attacker can’t forge one. But the attacker doesn’t have to forge anything. They sign in to your application with their own account, receive a perfectly valid encrypted token, and then plant that token in the page they use to attack a victim. It decrypts beautifully, so a server which only checks decryptability accepts it. The token has to say which session it belongs to, either by being stored against that session server-side or by being signed together with the session identifier, so that a token from the attacker’s session gets rejected when it turns up alongside the victim’s cookie.
- User-Agent matching is not protection. User-agent strings are neither secret nor stable: they can be copied trivially and they change legitimately whenever a browser updates. Treating them as evidence of token ownership is at best an inconvenience to an attacker and at worst a comfortable illusion that you’ve solved the problem.
Also worth stating plainly: all of this collapses the moment an attacker can get their own JavaScript running inside your page, which is a cross-site scripting (XSS) vulnerability, and that’s what happens when attacker-controlled text gets inserted into the page as markup rather than as text. Script running in your origin isn’t a foreign site. It can read your CSRF token straight out of the DOM and then issue the request with the token and the cookie, both of which it’s legitimately entitled to see. So CSRF protection assumes you don’t have an XSS vulnerability, and that’s why the encoding rules further down and the Content Security Policy section are part of the same defence rather than separate topics.
Some server calls aren’t made on any user’s behalf at all, like a nightly re-index, a warm-up request or a cache refresh. Those need a credential of their own rather than borrowing a user session, and two properties keep it contained. A narrow audience means the credential names exactly which service may accept it, so a token leaked from the re-index job can’t be presented to the payment API. A narrow scope means it grants exactly the operations that job needs, so the same token can’t delete a document. What must not exist is a general-purpose “SSR bypass” credential that skips the browser-facing protections, because once such a thing exists, every future endpoint is one copy-paste away from being unprotected. And the in-process gateway execution from Part 2 avoids a loopback HTTP call, but it still needs the caller’s authorization context to travel along 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, so in the .NET API right next to the write, and not in any of the following places, all of which get commonly mistaken for it:
- the absence of a button in the UI, which is a hint rather than a control;
- the route requiring a session, which only 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 mentioned, and that’s a genuinely useful layer: it can refuse to expose a field, or refuse a mutation, before anything downstream gets called at all.
It is not a substitute for the API doing the checking, though, and the reason is worth spelling out. A check in the gateway protects that route to the write, not the write itself. The restore operation is a method on an API that plenty of callers can reach: a scheduled job re-running a failed batch, an admin tool, a support script, a second gateway operation added next quarter by somebody who had no idea the first one was carrying the permission check, or a colleague calling the endpoint directly during an incident. Every single one of those bypasses the gateway. And if the only permission check lives there, then each new caller quietly becomes another way to restore any revision of any document.
So put the decision where the resource is, because that’s the one place every path has to go through. Treat the gateway check as an early rejection that saves you a round trip and gives a nicer error message. Useful, certainly, but not the control you’d want to defend in a review.
Is the Payload What It Claims to Be
Generated types are a build-time contract. At run time they’re simply gone, so the body of the restore request is an arbitrary JSON document until something actually validates it. Part 5’s point bears repeating right here, because it’s the most attractive false conclusion in this entire series: a generated type enforces nothing at run time.
So validate on the server: shape, types, ranges, identifiers, and whether the referenced revision belongs to the referenced document.
That last check deserves a sentence of its own, because it looks like input validation and is really authorization. The restore request carries two identifiers, a document and a revision, and the user is authorized for the document. Nothing in the request says the revision belongs to it. So an attacker who may edit document 42 sends { documentId: 42, revisionId: 99 }, where revision 99 belongs to a document they can’t read at all, and a handler that cheerfully loads revision 99 and writes its contents into document 42 has just copied data across a permission boundary using an operation the user was perfectly entitled to call. Checking that the revision’s parent document is the one being written closes that hole.
The same goes for data coming 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, meaning the escaping has to match where the value is being inserted, since the characters that are dangerous in element text differ from those in an attribute, a URL or a piece of inline script. And if it lands in a redirect or a URL, it needs validating. Rich text from a CMS is the highest-risk case of the lot, because it’s designed to become markup: you can’t escape it without destroying it, so it has to be sanitized against a known-good list of elements and attributes instead.
What Ends Up in the Page
SSR brings a disclosure channel that a client-rendered application simply doesn’t have: the payload. To avoid fetching the same data twice, the server serializes whatever it loaded during rendering into the HTML it sends, so the browser can resume from those values instead of re-requesting them. That serialized blob is the payload, and Part 12 explains why hydration depends on it. For security purposes the relevant property is a good deal simpler: it’s plain text sitting in the page, so anybody can read it with view-source. And the same goes for everything in runtimeConfig.public, per Part 6, which is published for precisely the same reason.
Two rules follow from that. Put only browser-intended values into public configuration, and treat a secret that has been there once and been deployed as disclosed, because removing it afterwards doesn’t retract it. And be deliberate about what your data fetching puts into transferred state: a query that selected internal fields for a server-side decision publishes those fields, and that’s exactly how internal user attributes or pricing logic end up visible in view-source.
Content Security Policy
A Content Security Policy is a response header listing which sources the browser is allowed to load code, styles, images and connections from. It doesn’t stop an injection happening; it limits the damage once one has. If a script tag does make it into your page, then a policy which only permits scripts from your own origin means the injected code can’t load the attacker’s payload from their domain, and a policy which restricts connect-src means it can’t post whatever it scraped to a server they control. That’s why this belongs right beside the CSRF discussion above rather than in a section of its own: the CSRF token protects a write, and CSP is what stops an injected script simply reading that token out of the page and using it.
Keeping the policy in the CMS so it can change without a deployment is convenient, and it also 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 switch the protection off.
Three qualifications. First, a fallback policy is not automatically stricter than the one it replaces, so define the intended relationship and test what actually happens when the CMS is unreachable.
Second, inline scripts need care. A policy can’t tell an inline <script> you wrote apart from one an attacker injected, since neither of them has a source URL, so the blunt fix is unsafe-inline, which permits both and throws away most of the protection. The targeted alternatives are a nonce, meaning a random value generated per response that you put both in the header and on your own script tags, or a hash of the script’s exact contents listed in the header. Either way the browser only runs the inline code you vouched for, and injected code has neither the current nonce nor a matching hash. I’m naming this because the deferral techniques in Part 14 involve small inline loaders, and the path of least resistance is always to weaken the whole policy to accommodate one of them.
And do remember that CSP is enforced by the browser. Assembling the header server-side doesn’t mean the protection acts before code reaches the client. It’s one layer alongside encoding, sanitization, dependency control and authorization.
Security Policy Across Environments
Ambiguity about authentication modes causes real gaps, so let’s 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’s a different thing from having none at all, and conflating the two is how a public application ends up with an unauthenticated write.
The harder discipline is not relaxing your protections just because an environment happens to be labelled non-production. Preview and integration environments hold real-looking data, talk to shared dependencies, and are reachable from the internet rather more often than anybody intends. They need access restriction of their own, and the production-equivalent security behaviour wants testing before a release rather than after one.
Diagnostics Endpoints and Secrets
Operational endpoints are the ones most likely to be left wide open, because they get added while somebody is debugging.
Keep a liveness probe minimal and separate from operator diagnostics. Worker metrics, log-level controls, profiling and heap capture all 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 stopping anybody actually reaching it.
Heap snapshots deserve a warning all of their own, because they’re the exception to careful logging: a snapshot contains whatever happened to be in memory, tokens and request bodies included, no matter how diligently your logger redacts. So treat captures as sensitive artefacts with bounded retention and restricted export, and give your logs the same policy: redact bodies and credentials, bound retention, restrict who can export.
For secrets themselves, two platform features are worth using wherever they exist. A managed identity lets the running service authenticate to a cloud resource using an identity the platform issues and rotates, so there’s no password sitting in your configuration to leak in the first place. A secret reference keeps the setting as a pointer, meaning “the value stored under this name in the vault”, which the platform resolves at startup, so the plaintext never appears in a configuration file, a repository or a deployment log. Part 16 covers how those references get wired into each environment.
But note what a reference does not do: it protects the value in storage and in transit to the process, and not afterwards. Once resolved, it’s an ordinary string in memory that application code can happily print, so keeping resolved values out of logs, bundles, screenshots and examples stays a code-review matter.
And finally, validate upstream TLS. When an internal service presents a certificate from a private certificate authority, Node.js rejects it, because it’s never heard of that CA. The quick fix everybody finds is to disable certificate verification globally with a single environment variable, and it works, which is precisely the problem: it disables verification for every outbound connection from the process, including the ones to your identity provider and your content service, so any attacker positioned on the network can impersonate them completely undetected. Configure the trust chain for the private CA instead. That’s a few lines of setup, and it leaves the rest of your connections protected.
A Review You Can Actually Run
So, back to our button. The trustworthy version of “restore this revision” has an answer at every 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 actually belongs to the document, a response that reveals nothing about what the user isn’t allowed to see, and a payload carrying no server-only data.
The transferable part here 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 very same request issued from another site?
- Authorization. Which component decides, and does it check the specific resource?
- Input. What validates the payload at run time, given that the types don’t?
- Output. What does the response, and the page’s payload, disclose?
And if any one of those answers is “it runs on the server”, then that boundary is unguarded. Server execution changes where code lives. It doesn’t 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: Nuxt in Production