Frontend security
In this series (12 parts)
- What frontend system design covers
- Rendering strategies: CSR, SSR, SSG, ISR
- Performance fundamentals: Core Web Vitals
- Loading performance and resource optimization
- State management at scale
- Component architecture and design systems
- Client-side caching and offline support
- Real-time on the frontend
- Frontend security
- Scalability for frontend systems
- Accessibility as a system design concern
- 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
- Never use
innerHTMLwith untrusted data. UsetextContentor a sanitization library like DOMPurify. - Escape output on the server. Template engines should auto-escape by default. Verify this is enabled.
- 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.comcalls an API atapi.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:3000and the API onlocalhost: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.
Secure cookie attributes
Every cookie should have the right attributes:
| Attribute | Purpose |
|---|---|
Secure | Only sent over HTTPS |
HttpOnly | Inaccessible to JavaScript (prevents XSS from reading it) |
SameSite | Controls cross-site sending behavior |
Path | Limits which paths the cookie is sent to |
Max-Age or Expires | Controls 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.
The recommended pattern
Use the Authorization Code flow with PKCE (Proof Key for Code Exchange):
- The SPA generates a random
code_verifierand derives acode_challengefrom it. - The user is redirected to the authorization server with the
code_challenge. - After login, the authorization server redirects back with an authorization code.
- The SPA exchanges the code and the
code_verifierfor tokens. The authorization server verifies thecode_verifiermatches 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
HttpOnlycookie set by your backend-for-frontend (BFF) proxy, or use silent refresh via a hidden iframe. Never store refresh tokens inlocalStorage.
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:
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains | Force HTTPS |
X-Content-Type-Options | nosniff | Prevent MIME type sniffing |
X-Frame-Options | DENY | Prevent clickjacking |
Referrer-Policy | strict-origin-when-cross-origin | Limit referrer leakage |
Permissions-Policy | camera=(), 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.