Search…

Frontend security

In this series (12 parts)
  1. What frontend system design covers
  2. Rendering strategies: CSR, SSR, SSG, ISR
  3. Performance fundamentals: Core Web Vitals
  4. Loading performance and resource optimization
  5. State management at scale
  6. Component architecture and design systems
  7. Client-side caching and offline support
  8. Real-time on the frontend
  9. Frontend security
  10. Scalability for frontend systems
  11. Accessibility as a system design concern
  12. Monitoring and observability for frontends

Prerequisite: Real-time on the frontend.

Frontend code runs in an environment you do not control. Users install browser extensions. Networks inject scripts. Attackers craft URLs designed to execute code in your origin. The browser provides strong security primitives, but only if you use them correctly.

This article covers the attacks that target frontends and the defenses built into the web platform. It connects to the broader security in system design perspective but focuses specifically on what happens in the browser.


Cross-Site Scripting (XSS)

XSS is the most common frontend vulnerability. An attacker injects executable code into your page, and the browser runs it with all the privileges of your origin: it can read cookies, make API calls, and exfiltrate data.

Types of XSS

Stored XSS: malicious script is persisted in the database (a comment, a profile field) and served to other users. This is the most dangerous type because it does not require the victim to click a crafted link.

Reflected XSS: the script is embedded in a URL parameter and reflected back in the response. The attacker tricks the user into clicking a link like https://app.com/search?q=<script>steal()</script>.

DOM-based XSS: the script never hits the server. Client-side JavaScript reads from an untrusted source (location.hash, postMessage) and writes it into the DOM unsafely.

Prevention

  1. Never use innerHTML with untrusted data. Use textContent or a sanitization library like DOMPurify.
  2. Escape output on the server. Template engines should auto-escape by default. Verify this is enabled.
  3. Content Security Policy (CSP) is your strongest defense. It tells the browser which sources of script, style, and other resources are allowed.
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src *;

A strict CSP blocks inline scripts entirely. Any injected <script> tag is refused by the browser before it executes.

Strict CSP with script nonces blocks nearly all XSS attacks. Report-only mode logs violations but does not prevent them.

CSP nonces

Instead of allowing 'unsafe-inline', generate a random nonce per request and include it on allowed script tags:

<script nonce="abc123">/* legitimate code */</script>
Content-Security-Policy: script-src 'nonce-abc123'

The attacker cannot guess the nonce, so their injected script is blocked.


Cross-Site Request Forgery (CSRF)

CSRF tricks the user’s browser into making an authenticated request to your server from a different origin. If the user is logged in, the browser attaches their cookies automatically.

sequenceDiagram
  participant User
  participant EvilSite as evil.com
  participant App as app.com

  User->>EvilSite: Visits evil.com
  EvilSite->>User: Page with hidden form
  Note over EvilSite: Form action = app.com/api/transfer
  User->>App: POST /api/transfer (with cookies)
  Note over App: Server sees valid session cookie
  App-->>User: Action executed (money transferred)

A CSRF attack exploits the browser’s automatic cookie attachment to make authenticated requests from a malicious page.

Prevention

CSRF tokens: the server generates a unique token per session (or per request) and embeds it in forms. The token is sent as a hidden field or custom header. Since the attacker’s page cannot read the token from your origin, they cannot forge a valid request.

SameSite cookies: set SameSite=Strict or SameSite=Lax on session cookies. Strict prevents the cookie from being sent on any cross-site request. Lax allows it on top-level navigation (clicking a link) but blocks it on form submissions and AJAX from other origins.

Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly

Double-submit cookie pattern: set a random value as both a cookie and a request header. The server verifies they match. An attacker can cause the browser to send the cookie but cannot read it to set the header.

For modern SPAs that use Authorization headers with bearer tokens instead of cookies, CSRF is largely a non-issue. The attacker cannot read the token from another origin, so they cannot attach it to a forged request.


CORS: when it is your problem

Cross-Origin Resource Sharing is not a security feature. It is a controlled relaxation of the same-origin policy. The browser blocks cross-origin requests by default. CORS headers tell the browser which origins are allowed.

When you hit CORS issues

  • Your SPA at app.example.com calls an API at api.example.com. Different subdomains count as different origins.
  • A third-party widget on your page tries to call your API.
  • Your dev server runs on localhost:3000 and the API on localhost:8080.

The preflight request

For non-simple requests (custom headers, PUT/DELETE methods, JSON content type), the browser sends an OPTIONS preflight before the actual request:

OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The server must respond with the allowed origins, methods, and headers:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Set Access-Control-Max-Age to cache preflight responses. Without it, every non-simple request triggers two HTTP round trips.

Never use Access-Control-Allow-Origin: * with credentials. The browser will reject it. If you need credentials, specify the exact origin.


Every cookie should have the right attributes:

AttributePurpose
SecureOnly sent over HTTPS
HttpOnlyInaccessible to JavaScript (prevents XSS from reading it)
SameSiteControls cross-site sending behavior
PathLimits which paths the cookie is sent to
Max-Age or ExpiresControls cookie lifetime

For session cookies, always use Secure; HttpOnly; SameSite=Lax at minimum. For sensitive operations, tighten to SameSite=Strict.


Subresource integrity (SRI)

When you load scripts from a CDN, you trust that CDN not to modify the file. SRI lets you verify the file has not been tampered with by including a hash in the script tag:

<script
  src="https://cdn.example.com/lib.js"
  integrity="sha384-abc123..."
  crossorigin="anonymous"
></script>

If the hash does not match, the browser refuses to execute the script. This protects against CDN compromises, which have happened to popular libraries in the past.

Generate SRI hashes with shasum or use your bundler’s SRI plugin.


OAuth token handling in SPAs

SPAs cannot keep secrets. The JavaScript source is visible to anyone. This rules out using a client secret for OAuth token exchange.

Use the Authorization Code flow with PKCE (Proof Key for Code Exchange):

  1. The SPA generates a random code_verifier and derives a code_challenge from it.
  2. The user is redirected to the authorization server with the code_challenge.
  3. After login, the authorization server redirects back with an authorization code.
  4. The SPA exchanges the code and the code_verifier for tokens. The authorization server verifies the code_verifier matches the original challenge.
sequenceDiagram
  participant SPA
  participant AuthServer as Auth Server
  participant API

  SPA->>SPA: Generate code_verifier + code_challenge
  SPA->>AuthServer: Redirect with code_challenge
  AuthServer-->>SPA: Authorization code
  SPA->>AuthServer: Exchange code + code_verifier
  AuthServer-->>SPA: Access token + refresh token
  SPA->>API: Request with access token
  API-->>SPA: Protected resource

OAuth Authorization Code flow with PKCE for SPAs. The code verifier proves the token request came from the same client that initiated the flow.

Token storage

  • Access tokens: store in memory (a JavaScript variable). They are short-lived. Losing them on page refresh is acceptable; you can use the refresh token silently.
  • Refresh tokens: store in an HttpOnly cookie set by your backend-for-frontend (BFF) proxy, or use silent refresh via a hidden iframe. Never store refresh tokens in localStorage.

localStorage is readable by any script running in your origin. A single XSS vulnerability exposes every token stored there.


Security headers checklist

Beyond CSP, several response headers harden your frontend:

HeaderValuePurpose
Strict-Transport-Securitymax-age=63072000; includeSubDomainsForce HTTPS
X-Content-Type-OptionsnosniffPrevent MIME type sniffing
X-Frame-OptionsDENYPrevent clickjacking
Referrer-Policystrict-origin-when-cross-originLimit referrer leakage
Permissions-Policycamera=(), microphone=()Disable unused browser APIs

Set these in your server configuration or CDN edge rules. They cost nothing and close common attack vectors.


What comes next

With security controls in place, you can think about scaling. Scalability for frontend systems covers CDN strategies, micro-frontends, feature flags, and how to deliver frontend code to millions of users without the build collapsing under its own weight.

Start typing to search across all content
navigate Enter open Esc close