Skip to content

Cybersecurity Engineering Handbook / Chapter 26

Secure Frontend and Client-Side Engineering

Prevent client-side compromise by treating browser code as exposed code, controlling dangerous sinks, and keeping authorization on the server.

An Acme support agent opens an invoice in the administrative console and requests the export designed in Chapter 25. The page renders a customer-supplied company name, holds the agent’s session, calls the export API, and loads a support widget. A single browser tab now contains untrusted text, privileged authority, sensitive data, and code from more than one owner.

Hiding the export button from ordinary users does not protect this path. Neither does checking a role in JavaScript. An attacker can change the client, call the API directly, place markup in the company name, frame the console, induce a cross-site request, or compromise code the page imports. The browser can constrain several of those paths, but it cannot decide who may export an invoice.

The engineering task is to keep data from acquiring instruction power in the client, reduce what compromised client code can steal or invoke, and require the server to authorize every consequential action.

XSS control path showing untrusted input entering a client application, flowing toward a dangerous sink, and being constrained by template escaping, sanitization, Trusted Types, Content Security Policy reporting, and server-side authorization.
XSS prevention is a path control. Untrusted input must lose instruction power before it reaches a browser sink; CSP and reports reduce blast radius but do not replace safe output handling or server authorization.

Begin with the authority in the tab

An origin—the combination of scheme, host, and port—is the browser’s principal isolation boundary. The same-origin policy limits how documents and scripts from one origin interact with resources from another. It is a valuable containment mechanism, but it is not Acme’s tenant or role model. Two users of https://support.acme.example share an origin even when only one may request an export.

List what the invoice tab can do before choosing controls. It can render customer data, send authenticated requests, receive API responses, open windows and frames, persist values, and execute first- and third-party code. Then identify which authority arrives automatically. A cookie may accompany a request without JavaScript reading it; a bearer token may be available to any script in the page; a desktop shell may expose native bridges; a service worker may outlive the visible page. Each capability creates a different theft and abuse path.

The server remains the final policy boundary. The export endpoint must authenticate the caller, authorize the tenant, invoice, fields, purpose, and current privilege, and bind the work to the approval record from Chapter 25. Test that contract without the UI: change the invoice identifier, tenant, fields, and role; omit any client-supplied state that the UI normally sends; replay the request; and call it from a different client. A disabled button may improve usability. It supplies no evidence about server authorization.

Keep the customer name as text

Suppose the stored company name is:

<img src=x onerror="requestExport()">

The string becomes dangerous only when code places it into a context that interprets markup or script. Render it through the framework’s ordinary text interpolation and it remains visible text. Pass it through innerHTML, outerHTML, insertAdjacentHTML, an unsafe framework escape hatch, a string-built URL, or dynamic script creation and it may become an instruction.

Inventory these sinks instead of searching only for a familiar payload. HTML, JavaScript, CSS, URL, event-handler, navigation, and script-loading contexts have different rules. Use framework auto-escaping for normal output. Restrict or wrap dangerous DOM APIs so exceptional use is visible in code review. Static analysis and tests should cover the wrappers and the product paths that feed them.

Rich text is the difficult case because the product intentionally preserves some markup. Define the allowed elements, attributes, URL schemes, and embedding behavior; sanitize with a maintained library built for the target context; and keep sanitization close to the sink. Do not decode, concatenate, or transform the result afterward in a way that recreates active syntax. Store the original when the product needs it, but sanitize again for the destination rather than assuming one stored transformation is safe everywhere.

Trusted Types can narrow high-risk applications further by requiring designated policy code to produce values for supported injection sinks. It turns an accidental sink assignment into a development or runtime violation. It does not decide whether a policy’s sanitizer is correct, cover every client platform, or make existing unsafe policy code trustworthy.

The figure above is therefore a path, not a stack of interchangeable defenses. Safe rendering removes instruction power at the sink. Sanitization permits a deliberately small markup language. Trusted Types constrains who may feed supported sinks. Content Security Policy limits which scripts the document may execute and provides reports. Server authorization limits what a successful script can cause the service to do.

Roll out a policy the application can enforce

A Content Security Policy that lists broad script hosts and permits unsafe inline execution may be syntactically present while preserving the attacker paths that matter. Build the policy from observed application needs:

  1. Inventory first-party bundles, inline code, workers, frames, connections, styles, fonts, images, and third-party resources on representative routes. Remove sources that have no current owner or purpose.
  2. Deliver a restrictive candidate in report-only mode. Use nonces or hashes for the inline scripts that must remain, and remove inline event handlers and string-to-code construction rather than granting a general exception.
  3. Collect reports at an endpoint designed for hostile, high-volume input. Sample or rate-limit them, strip unnecessary sensitive data, and distinguish policy defects from attack signals.
  4. Exercise login, error, support, payment, administration, and rarely visited routes. A policy tested only on the landing page is not ready.
  5. Enforce on a narrow cohort or route, watch both reports and product failures, then expand. Keep the policy and its tests with the application so a new dependency cannot silently widen it.

For the support console, deny embedding with the CSP frame-ancestors directive. X-Frame-Options can remain for older compatibility where needed, but it cannot express the same policy range and should not become a conflicting second source of truth. Test the actual sensitive routes inside frames from allowed and disallowed origins. Frame denial prevents a hostile site from visually steering an agent through the console; it does not replace authorization or CSRF protection.

Choose what survives a page reload

No secret survives being shipped to an untrusted client. Values compiled into JavaScript, desktop or mobile packages, source maps, public environment variables, configuration responses, or cached assets must be treated as discoverable. A publishable API identifier is not a credential merely because its name contains KEY. A credential capable of privileged server work belongs behind a server boundary.

Browser applications are public OAuth clients and cannot keep a client secret. Use authorization code with PKCE for browser-based authorization flows. Validate redirects and state according to the identity design in Chapter 23. When the architecture can support it, a backend-for-frontend can hold tokens server-side and give the browser a narrowly scoped session instead.

Storage is a threat decision. A long-lived bearer token in localStorage persists across reloads and is readable by successful script injection. An in-memory token reduces persistence but remains available to executing page code and complicates refresh. An HttpOnly cookie denies JavaScript direct access, but the browser still attaches it to matching requests; use Secure, a deliberately scoped domain and path, and an explicit SameSite setting, then address CSRF. IndexedDB, session storage, cache storage, desktop preference stores, and mobile local stores are persistence mechanisms, not secret vaults by default.

Record what can steal or use the session, how long the authority lasts, how refresh works, how reuse or theft is detected, and how sessions are revoked. Short expiry limits an incident only if renewal does not silently recreate the same authority forever.

Separate cross-origin reading from server permission

CORS tells a browser which origins may read a cross-origin response. It does not authenticate the requester, authorize an export, or stop a script or command-line client from sending the request.

If Acme intentionally serves the console and API from different origins, return an allow-listed origin only after an exact comparison. Do not reflect arbitrary input. A credentialed response cannot use Access-Control-Allow-Origin: *; keep allowed methods and headers as narrow as the real API contract, and vary caches correctly when the response depends on Origin. Public read APIs, authenticated product APIs, administration APIs, and internal service APIs should not inherit one global policy.

Exercise the boundary with an expected origin, an untrusted origin, null, no Origin header, credentials on and off, simple requests, preflighted headers, and state-changing methods. Inspect both halves of the result: whether browser code may read the response and whether the server performed the action. A blocked read is not an acceptable outcome if an unauthorized export was still queued.

Make an induced request harmless

CSRF is possible when a browser automatically supplies authority to a request initiated from another site. The export request, login, logout, password or email change, invitation, payment, and administrative mutation all deserve state-changing semantics even when a product has implemented them as convenient links.

Use SameSite=Lax or Strict where the product flow permits it, but do not treat cookie defaults as the whole defense. For sensitive cookie-authenticated actions, validate an unpredictable CSRF token bound to the session or use an equivalent well-understood pattern. Reject methods and content types the endpoint does not need. Origin or Fetch Metadata checks can add defense in depth when compatibility is understood.

A required custom header can help because an ordinary cross-site form cannot set it. That reasoning holds only when the endpoint rejects simple submissions and CORS does not grant an untrusted origin permission to send the header with credentials. Test the complete condition instead of naming the header as a control.

Decide which other code shares the page

The support widget in Acme’s console executes with the page’s authority unless the design isolates it. It may read rendered invoice data, observe interaction, call reachable endpoints, or change the DOM. Package provenance and vendor review matter, but neither changes that runtime fact.

Prefer no third-party script on identity, administration, billing, health, production-control, source-code, and other privileged surfaces. When the product accepts an exception, record:

  • the accountable owner and present business purpose;
  • the exact routes on which the code loads and the data and browser capabilities it can reach;
  • the vendor, delivery path, review result, and notification expectations;
  • the version or integrity constraint, monitoring, failure behavior, review date, and removal path.

Subresource Integrity can detect a changed cross-origin static resource when Acme intentionally pins its bytes. It does not help when a tag manager or vendor URL is expected to change without a corresponding integrity update, and it does not make approved bytes safe. Sandboxed frames, separate origins, message schemas, and data minimization can provide stronger isolation when the integration permits them.

Apply the same suspicion to the first-party dependency graph. Commit lockfiles, use reproducible installation modes, review unexpected dependency and lifecycle-script changes, constrain registries, and scan built bundles for secrets and unplanned code. Source maps need an explicit publication decision; hiding them is not a substitute for keeping credentials and sensitive implementation data out of the bundle.

Desktop and mobile clients add update channels, native bridges, deep links, local files, and operating-system permissions. Expose the smallest native interface, validate every message at the boundary, authenticate update metadata and packages, and assume a modified client can still call the service. Native packaging may protect distribution integrity. It does not turn the client into a trusted policy engine.

Review one complete client path

Return to the support agent and the invoice export. A useful review follows the actual path rather than collecting browser headers in isolation:

  • Render the hostile company name through every invoice view, notification, preview, and error path. Show that ordinary text never reaches an executable sink and that permitted rich content passes through the reviewed sanitizer and sink policy.
  • Read the enforced CSP and recent reports beside the application’s resource inventory. Prove that unexpected scripts, frames, and connections fail without granting broad exceptions.
  • Trace the session from login through refresh, page reload, theft, expiry, and revocation. Verify cookie attributes or token storage against the chosen attacker model.
  • Call the export API without the UI and vary tenant, invoice, fields, method, origin, CSRF token, and caller privilege. Confirm that the server denies the work before it is queued.
  • Attempt to frame the sensitive route and to submit it from another site. Check the action as well as what the attacking page can observe.
  • List every script and package that reaches the route, including tag-manager rules. Match each third party to an owner, narrow scope, delivery constraint, review date, and removal mechanism.
  • Inspect production bundles, configuration, caches, source maps, desktop packages, and mobile packages for credentials, private data, debug authority, and obsolete endpoints.

The browser cannot be made trustworthy. It can be made a much less forgiving place for attacker-controlled instructions, stolen authority, induced requests, unexpected origins, hostile frames, and mutable dependencies. The export is secure only when those client defenses narrow the path and the server still refuses every action the agent is not entitled to perform.