Single Page Application Security Testing: React, Angular, and Vue
SPAs shift rendering from the server to the client, and with it they shift a surprising number of security responsibilities. Authorization logic that was traditionally enforced server-side now appears in client-side route guards. Session tokens that lived in HttpOnly cookies now get stored in localStorage. Templating that happened safely on the server now happens in the browser DOM, where injection is harder to reason about.
Most web scanners were designed for server-rendered applications. They miss SPA-specific vulnerabilities entirely: client-side route bypass, DOM-based XSS in framework templates, PKCE flow weaknesses, and postMessage injection. This guide covers what to test and how.
Understanding SPA Architecture for Security Testing
Before testing, understand how the SPA you're evaluating works:
- How does authentication work? JWT in localStorage/sessionStorage, or HttpOnly cookie? This determines the XSS impact.
- Where is authorization enforced? Client-side route guards only, or server-side on every API call? Client-only is always a finding.
- How does the SPA communicate with the backend? REST API, GraphQL, WebSocket, or a mix?
- What third-party libraries are in use? Outdated dependencies (React 16, Angular 11) carry known CVEs.
# Extract JavaScript bundles to understand the application:
# Look at Network tab in DevTools, filter by JS
# Main bundle often reveals the routing structure
# Deobfuscate/beautify minified JS:
# Browser DevTools: Sources tab → {} icon to pretty-print
# Or use online tools like js-beautify
# Find API endpoints in the bundle:
strings main.chunk.js | grep -E '"/api/|"https://api\.' | sort -u
grep -o '"\/[a-zA-Z0-9_\/-]*"' main.chunk.js | sort -u | head -50
# Find hardcoded values (API keys, credentials, debug flags):
grep -iE "apikey|api_key|secret|password|token|debug" main.chunk.js | head -20
# Find route definitions:
grep -E "path:|route:|'/|\"/" main.chunk.js | head -30
Test 1: Client-Side Route Authorization Bypass
SPAs use route guards to hide "protected" views from unauthenticated users. Developers sometimes treat a hidden route as a secured route — which it is not. The route guard only controls whether the component renders, not whether the underlying API calls succeed.
# Test client-side route guards:
# 1. Log out of the application
# 2. Navigate directly to a protected route via URL bar
# Example: https://app.example.com/admin/users
# In React Router:
# Protected routes using or similar wrappers
# Bypass: navigate directly to the path — if the component renders data,
# the API endpoint isn't enforcing auth server-side
# In Angular:
# Route guards (CanActivate) check auth client-side
# Test: DELETE /api/tokens (log out), then navigate to /dashboard/admin
# Check for lazy-loaded modules that bypass auth:
# Angular lazy loads: /admin → admin.module.js
# Try loading the module URL directly in browser dev tools
# The real test is whether the API call works, not whether the UI renders
# Intercept the API requests made by the "protected" route
# Replay them without a valid JWT or session cookie
What to Look For
- UI components rendering sensitive data after navigating directly to a protected route without authentication
- API calls that succeed without an Authorization header
- Role-based views (admin panel) that render when you switch user type client-side
Test 2: DOM-Based XSS in Framework Templates
React, Angular, and Vue all protect against XSS by default — but each framework has escape hatches that introduce DOM-based XSS when used carelessly.
React: dangerouslySetInnerHTML
// Vulnerable pattern in React:
function UserBio({ bio }) {
return ;
}
// Test: inject HTML in any user-controlled field that feeds into dangerouslySetInnerHTML
// Payload:
// Look for it in profile fields, comments, descriptions, rich text editors
// Find dangerouslySetInnerHTML in source:
grep -r "dangerouslySetInnerHTML" src/ --include="*.jsx" --include="*.tsx"
// Also check template literals fed into innerHTML:
document.getElementById('output').innerHTML = userInput; // DOM XSS
element.insertAdjacentHTML('beforeend', userInput); // DOM XSS
Angular: bypassSecurityTrustHtml
// Vulnerable Angular pattern:
constructor(private sanitizer: DomSanitizer) {}
sanitizedHtml = this.sanitizer.bypassSecurityTrustHtml(userInput);
// Used in template as: [innerHTML]="sanitizedHtml"
// Angular also escapes by default, but [innerHTML] with unsanitized input is dangerous
// Test any field that renders HTML content
// Find in source:
grep -r "bypassSecurityTrust\|innerHTML" src/ --include="*.ts" --include="*.html"
// Angular template injection — different from SSTI but similar concept:
// Angular doesn't allow template injection in production (AOT compilation)
// but development builds or improperly escaped string interpolation can allow it
Vue: v-html
// Vulnerable Vue pattern:
// Vue's v-html does NOT sanitize — it renders raw HTML
// Any user-controlled content passed to v-html is XSS
// Find in source:
grep -r "v-html" src/ --include="*.vue"
// Template injection via expression:
// Vue 2 (deprecated) allowed: {{ constructor.constructor('alert(1)')() }}
// Modern Vue 3 mitigates this but test anyway in older apps
// Test payload for v-html:
// 
DOM XSS Sinks to Test
# Test these DOM sinks with attacker-controlled input:
# innerHTML, outerHTML, insertAdjacentHTML
# document.write, document.writeln
# eval, setTimeout(string), setInterval(string), Function(string)
# location.href, location.replace, location.assign (URL-based XSS)
# src attribute of script tags
# Sources of attacker-controlled data:
# location.hash, location.search, location.href (URL parameters)
# document.referrer
# window.name
# postMessage data
# localStorage / sessionStorage (if populated from URL params)
Test 3: JWT Storage and Token Theft
SPAs frequently store JWTs in localStorage because cookies require server-side configuration. This creates a significant XSS impact difference: an XSS in a cookie-based application can read session cookies only if HttpOnly is not set; an XSS in a localStorage-based application always has access to the JWT.
# Check where tokens are stored:
# Browser DevTools > Application > Local Storage
# Browser DevTools > Application > Session Storage
# Browser DevTools > Application > Cookies
# localStorage JWT → XSS means full account takeover
# HttpOnly cookie → XSS cannot read the session token (but can still make requests)
# Test localStorage token theft via XSS:
# Check token expiry and refresh logic:
# Decode the JWT: atob(token.split('.')[1])
# Check "exp" claim — what is the token lifetime?
# Tokens with no expiry or very long expiry (days/weeks) are high impact
# Check if refresh tokens are stored:
# Refresh tokens in localStorage are even more dangerous — longer-lived
localStorage.getItem('refresh_token')
sessionStorage.getItem('refreshToken')
Test 4: CORS Misconfiguration in SPA APIs
SPAs make cross-origin API calls, so CORS is always present. The API server must explicitly allow the frontend's origin. Misconfigurations here allow arbitrary origin access or credentialed cross-origin requests.
# Test CORS configuration on the API:
curl -H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: GET" \
-X OPTIONS \
https://api.example.com/v1/user/profile -v
# What to look for:
# Access-Control-Allow-Origin: * (with credentials: dangerous if combined)
# Access-Control-Allow-Origin: https://evil.com (reflected origin — critical)
# Access-Control-Allow-Credentials: true with wildcard or reflected origin
# Reflected origin test — does the server mirror your Origin header?
curl -H "Origin: https://attacker.com" \
https://api.example.com/v1/user \
-H "Authorization: Bearer TOKEN" -v
# Check if response contains: Access-Control-Allow-Origin: https://attacker.com
# Null origin bypass:
curl -H "Origin: null" https://api.example.com/v1/user -v
# If Access-Control-Allow-Origin: null is returned, exploitable via sandboxed iframe
# Subdomain wildcard — test subdomains:
curl -H "Origin: https://attacker.example.com" https://api.example.com/v1/user -v
CORS is covered in depth in our CORS misconfiguration testing guide, but it deserves extra attention in SPA contexts where complex origin relationships exist between the frontend CDN, API server, and authentication service.
Test 5: OAuth PKCE Flow Security
SPAs can't store client secrets, so they use OAuth 2.0 with PKCE (Proof Key for Code Exchange). PKCE prevents authorization code interception, but its implementation must be correct.
# Intercept the OAuth authorization flow in Burp Suite
# Look for:
# 1. PKCE code_challenge in the authorization request
# 2. code_verifier in the token exchange request
# Test 1: Is PKCE actually enforced?
# Try exchanging the authorization code WITHOUT code_verifier
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://app.example.com/callback
&client_id=CLIENT_ID
# No code_verifier — does the server reject this?
# Test 2: Is the state parameter validated?
# State prevents CSRF on the OAuth redirect
# Initiate login, capture the state value
# Change the state in the callback URL to a different value
# If the server doesn't reject the modified state, CSRF is possible
# Test 3: Is the redirect_uri validated exactly?
# Allowed: https://app.example.com/callback
# Test: https://app.example.com/callback/../../../evil
# Test: https://evil.com?x=https://app.example.com/callback (common bypass)
# Test 4: Authorization code reuse
# Complete the OAuth flow once, capture the authorization code
# Try using the same authorization code a second time
# Reuse should be rejected
Test 6: postMessage Vulnerabilities
SPAs frequently use postMessage for cross-frame communication — with OAuth callbacks, payment iframes, analytics, and third-party widgets. Insufficient origin validation in message listeners is a DOM XSS vector.
// Find postMessage listeners in the application:
grep -r "addEventListener.*message\|onmessage" src/ --include="*.js" --include="*.ts"
// Vulnerable pattern — no origin check:
window.addEventListener('message', (event) => {
document.getElementById('content').innerHTML = event.data; // DOM XSS!
});
// Secure pattern:
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted-parent.example.com') return;
// safe to process event.data
});
// Test from browser console:
// Navigate to the target application
// Open DevTools console
window.postMessage('
', '*')
// Or target a specific origin:
window.postMessage({ type: 'auth', token: 'injected' }, 'https://app.example.com')
// postMessage as XSS source in React:
useEffect(() => {
window.addEventListener('message', (e) => {
setUserContent(e.data); // If rendered with dangerouslySetInnerHTML → XSS
});
}, []);
Test 7: Client-Side Data Exposure
SPAs often over-fetch data and rely on the frontend to filter what's displayed. The API returns the full user object; the UI only shows some fields. But the attacker can see all fields in the response.
# Inspect API responses for over-fetched data:
# Open DevTools > Network tab
# Filter by XHR/Fetch requests
# Look at response bodies for fields not shown in the UI
# Common patterns:
# GET /api/v1/user/profile returns all user fields including role, internal IDs
# GET /api/v1/orders returns all order details including internal notes
# GET /api/v1/users returns a list including password hashes (seen in misconfigured apps)
# Check if user roles or permissions are included in API responses:
# {"id": 1, "email": "user@example.com", "role": "user", "can_admin": false}
# What happens if you modify this client-side and replay?
# Check state management stores (Redux, Vuex, Pinia):
# Redux DevTools extension exposes the full client-side state tree
# Look for sensitive data cached in the store that shouldn't be there
# Check bundle for API keys or secrets:
strings app.bundle.js | grep -iE "apiKey|authToken|secret|password" | head -20
Test 8: Content Security Policy Effectiveness in SPAs
SPAs often have weak CSP because inline scripts, dynamic eval, and CDN-hosted resources create exceptions that undermine the entire policy. A CSP that allows 'unsafe-inline' or 'unsafe-eval' provides no XSS protection.
# Check the CSP header:
curl -I https://app.example.com | grep -i "content-security-policy"
# Test via SecurityHeaders.com (scan the URL)
# Or check manually:
# Weak patterns:
# Content-Security-Policy: script-src 'unsafe-inline' 'unsafe-eval' *
# Content-Security-Policy: script-src cdn.example.com *.cloudflare.com
# (wildcard CDN — can bypass with a file upload to CDN)
# Check for CSP bypass via allowed CDN domains:
# If script-src includes cdn.jsdelivr.net or similar user-content CDNs:
# An attacker can upload a malicious script to jsdelivr and load it from the allowed domain
# Report-only mode:
# Content-Security-Policy-Report-Only: → Not enforced, just reported
# If only Report-Only is set, there is no CSP enforcement at all
# Check meta tag CSP (weaker than header):
grep -i "content-security-policy" index.html
# Meta tags cannot restrict certain directives (frame-ancestors, report-uri)
Test 9: Third-Party Script Supply Chain Risk
SPAs load analytics, A/B testing, customer support, and advertising scripts from third-party domains. Each of these is a potential supply chain attack vector — a compromised CDN or third-party script account can inject malicious code into every user's session.
# Check what third-party scripts are loaded:
# DevTools > Sources > look at loaded domains
# Or check the HTML source:
grep -E '
# Unsafe: