
SPAs break traditional pentesting because the server doesn’t render pages. The browser does. A React, Angular, or Vue application ships a JavaScript bundle to the client, and that bundle builds every view, handles every route transition, and manages every API call at runtime. For a security scanner that expects to crawl <a href="/page"> links and parse server-rendered HTML, the SPA looks like a single blank page with a <div id="root"></div>.
This isn’t a minor inconvenience. It’s a fundamental mismatch between how most DAST tools work and how modern web apps are built. If your pentest methodology was designed around server-side rendering, you’re testing 2010-era architecture against 2026-era applications. The result? Missed routes, missed API endpoints, missed vulnerabilities.
According to the 2024 State of JS survey, React alone powers over 50% of production web applications. Angular and Vue account for a significant share of the rest. If your pentesting toolkit can’t handle client-side rendering, you’re blind to the majority of web applications deployed today.
SPAs shift security-critical logic from the server to the browser, which changes the threat model entirely. In a traditional multi-page application, the server controls routing, session management, and page rendering. In a SPA, the client handles all three.
Here’s what changes:
Routing lives in JavaScript. When a user visits /dashboard/admin/settings, no HTTP request goes to the server for that specific path. The JavaScript router (React Router, Angular Router, Vue Router) parses the URL fragment or path and renders the corresponding component. The server never sees individual route requests, which means server-side access controls on those routes don’t exist unless the developer explicitly built API-level authorization.
State lives in the browser. SPAs use state management libraries (Redux, NgRx, Vuex/Pinia) to store application data on the client. That state often includes user roles, permissions, feature flags, and even tokens. All of it is inspectable through browser DevTools.
API communication is decoupled. Instead of form submissions and page reloads, SPAs fire async HTTP requests (fetch/XHR) to REST or GraphQL endpoints. The API is a separate attack surface from the UI, and testing one without the other leaves gaps.
The DOM changes without page loads. New content appears through JavaScript DOM manipulation, not server responses. A scanner that triggers on DOMContentLoaded and then reads the HTML will miss every view that requires user interaction to render.
| Traditional Web App | Single-Page Application |
|---|---|
| Server renders each page | Browser renders all views from JS bundle |
| Routes map to server endpoints | Routes handled by JS router (no server request) |
| Session cookie sent per request | JWT or token in localStorage/sessionStorage |
| Forms submit via POST | API calls via fetch/XHR (JSON payloads) |
Crawlable <a href> links | Dynamic navigation via history.pushState() |
SPAs introduce a category of vulnerabilities that either don’t exist in traditional web apps or behave differently enough to require new testing techniques. Here are the ones that matter most.
DOM-based XSS happens when user input flows through JavaScript DOM manipulation without sanitization. Unlike reflected or stored XSS, the payload never touches the server. It executes entirely in the browser.
SPAs are especially prone to this because they use innerHTML, dangerouslySetInnerHTML (React), or [innerHTML] binding (Angular) to render dynamic content. If a URL parameter, hash fragment, or postMessage payload reaches one of these sinks, you’ve got XSS.
Common sources in SPAs: location.hash, location.search, document.referrer, window.name, postMessage data, and URL parameters parsed by the client-side router.
SPAs frequently store authentication tokens in localStorage or sessionStorage instead of httpOnly cookies. This is dangerous because any XSS vulnerability on the page can read localStorage with a single line of JavaScript: localStorage.getItem('token'). An httpOnly cookie, by contrast, is invisible to JavaScript.
You’ll also find API keys, user profile data, feature flags, and sometimes full API responses cached in client-side storage. Check localStorage, sessionStorage, IndexedDB, and even window.__INITIAL_STATE__ (used by server-side rendering hydration in frameworks like Next.js and Nuxt).
Webpack, Vite, Rollup, and other bundlers compile all application code (and sometimes configuration) into JavaScript files served to the browser. Developers who reference API keys, internal endpoints, or secret tokens in their source code often don’t realize those values end up in the production bundle, readable by anyone.
Common findings: AWS access keys, Stripe publishable/secret key pairs, internal microservice URLs, feature flag configurations, admin API endpoints not documented anywhere, and hardcoded credentials for staging environments.
SPAs often implement access controls purely in the frontend. A React component might check user.role === 'admin' before rendering an admin panel. But if the underlying API endpoint doesn’t enforce the same check, any user who calls the API directly (or just edits the JavaScript in DevTools) can access admin functionality.
This is one of the most common SPA findings: the UI hides features based on roles, but the API serves data to anyone with a valid token.
SPAs that use history.pushState() can leak sensitive data through browser history entries. If a search query, patient ID, or financial record identifier appears in the URL path or query string, it ends up in the browser history, potentially accessible to anyone with physical access to the device or through a shared browser profile. The history.state object itself can also carry data between route transitions.
Most DAST tools were built for server-rendered applications, and they fail against SPAs in predictable ways. OWASP ZAP’s default spider follows <a href> links and form actions in the HTML response. Burp Scanner’s crawler does the same. Neither executes JavaScript by default in their crawling phase.
Here’s what that means in practice:
They miss client-side routes. A React app with 50 routes in its router configuration looks like one page to a non-JS crawler. ZAP’s spider returns a single URL. The other 49 routes, along with all their associated API calls and input fields, are invisible.
They can’t interact with UI components. A dropdown menu that loads options via API call, a modal that appears on button click, an infinite-scroll list, a multi-step form wizard: none of these render without JavaScript execution and user interaction. A crawler that doesn’t click buttons can’t reach them.
They miss authenticated states. SPAs often manage authentication entirely in JavaScript. The login flow stores a JWT in memory or localStorage, and every subsequent API call includes it as a Bearer token. A traditional crawler that relies on cookie-based session management can’t maintain an authenticated SPA session.
They can’t handle dynamic DOM changes. SPAs constantly modify the DOM in response to user actions and API responses. A scanner that takes one snapshot of the DOM after page load misses every state change. That’s where the vulnerabilities hide.
| Scanner Limitation | Impact on SPA Testing |
|---|---|
| No JavaScript execution | Sees only the initial HTML shell |
| Link-based crawling only | Misses all JS router-defined routes |
| No UI interaction capability | Can’t trigger modals, dropdowns, form wizards |
| Cookie-based session handling | Can’t maintain JWT-based auth |
| Single DOM snapshot | Misses dynamically rendered content |
ZAP’s AJAX Spider (which uses a headless browser) helps somewhat, but it still relies on heuristic clicking rather than understanding the application’s actual navigation flow. It’ll click random elements and hope to discover routes, which gives incomplete coverage and produces noisy results.
Your Scanner Is Missing 40-60% of Your SPA’s Routes. Start Testing What It Can’t See.
You just read why traditional DAST tools fail against SPAs — no JavaScript execution, no UI interaction, no client-side route discovery. Start a free trial to test your React, Angular, or Vue application with an AI agent that navigates your SPA the way a real user does.
First pentest running in under an hour. No procurement needed.
Start with a proxy-first setup. Route all browser traffic through Burp Suite or OWASP ZAP, then manually browse the application while the proxy records every request. This is the most reliable way to map a SPA’s API surface.
Burp Suite Professional with the embedded browser. Burp’s embedded Chromium browser sends all traffic through the proxy automatically. This is your primary testing tool. Use the Proxy > HTTP History tab to capture every API call the SPA makes as you navigate.
Chrome DevTools. Open the Network tab to watch API calls in real time. The Sources tab lets you inspect JavaScript bundles. The Application tab shows localStorage, sessionStorage, cookies, IndexedDB, and service workers. The Console tab is where you’ll test for DOM-based XSS sinks.
Browser extensions for Burp. If you’re using your regular browser instead of Burp’s embedded one, install FoxyProxy or the Burp Suite browser extension to route traffic through the proxy.
webpack-bundle-analyzer or source-map-explorer. If the application ships source maps (many staging and some production deployments do), these tools visualize the entire bundle contents, making it trivial to find hardcoded secrets, internal API paths, and third-party library versions.
Retire.js. Scans JavaScript libraries for known vulnerabilities. Run it against the SPA’s loaded scripts to find outdated jQuery, Lodash, Angular, or other libraries with published CVEs.
Semgrep with JavaScript/TypeScript rulesets. Static analysis on the SPA’s client-side code (if you have source access) catches DOM XSS patterns, insecure postMessage handlers, and unsafe eval() usage before you even start dynamic testing.
This manual browsing phase is critical because it’s the only reliable way to map a SPA’s full attack surface. Automated crawlers can supplement it, but they can’t replace it.
Test by accessing routes directly that should be restricted to higher-privilege roles, and verify the API enforces the same restrictions. In most SPAs, the router checks a local variable (like isAdmin in the Redux store) before rendering a view. Bypassing that check is trivial.
<Route path="/admin" or createBrowserRouter configurationsRouterModule.forRoot or loadChildren in lazy-loaded modulesroutes array in the router config/admin/users isn’t shown in the nav for your low-privilege user, try visiting it directly./login or /unauthorized (properly implemented client-side guard, but still test the API directly)The pattern here is consistent: never trust client-side routing to enforce access control. Always verify at the API layer.
Download the SPA’s JavaScript bundles and search them for secrets, internal URLs, and configuration values that shouldn’t be exposed to end users. Every SPA ships its compiled JavaScript to the browser, and developers regularly embed sensitive values during the build process.
main.abc123.js or app.bundle.js in the static/js/ or assets/ directory.{} (Pretty Print) button to format the minified code.apiKey, api_key, API_KEYsecret, password, token, credentialAWS_ACCESS_KEY, STRIPE_, FIREBASE_internal., staging., dev., .localBearer, Basic (hardcoded auth headers)-----BEGIN (private keys, yes, people actually bundle these)If source maps are available (check for .map files alongside the JavaScript bundles or look for //# sourceMappingURL= comments), use source-map-explorer or webpack-bundle-analyzer to reconstruct the original source tree.
For source-level access, run Semgrep with its p/secrets ruleset:
semgrep --config p/secrets ./src/
This catches API keys, private keys, and credentials that made it into the codebase. Combine it with trufflehog for git history scanning to find secrets that were committed and then “removed” (they’re still in the git log).
Also check environment variable usage. React apps expose any variable prefixed with REACT_APP_, Vue CLI exposes VUE_APP_, and Angular makes environment.ts available in the bundle. If a developer puts a secret in any of these, it ships to every user’s browser.
Found Secrets in Your JS Bundles? Get a Full SPA Security Assessment.
Hardcoded API keys and exposed internal endpoints are just the surface. A comprehensive SPA pentest covers client-side routing bypass, DOM XSS, JWT mishandling, and the API layer underneath. Request a quote for a thorough assessment of your single-page application.
Custom scoping for React, Angular, and Vue applications.
Trace user-controllable inputs from their source (URL parameters, hash fragments, postMessage) to dangerous sinks (innerHTML, eval, document.write) in the JavaScript code. DOM XSS in SPAs is harder to find with automated scanners because the payload never appears in an HTTP response.
?search=<payload> parsed by the SPA’s router or custom query-string parser#section=<payload> commonly used in Angular’s HashLocationStrategyIn React: dangerouslySetInnerHTML, direct DOM manipulation via refs, and eval() calls. React’s JSX escapes content by default, so XSS usually requires one of these explicit bypasses.
In Angular: [innerHTML] binding, bypassSecurityTrustHtml(), and the DomSanitizer service being misused to whitelist unsanitized content.
In Vue: v-html directive, which renders raw HTML without sanitization.
<img src=x onerror=alert(1)> and framework-specific payloads.window.addEventListener('message', function(e) {
console.log('postMessage received:', e.origin, e.data);
});
Then interact with the application to see what messages are being sent between frames.
These three vulnerability classes show up repeatedly in SPA pentests, and each requires specific testing approaches.
SPAs that use JWTs for authentication face a storage dilemma. Storing the token in localStorage makes it accessible to XSS. Storing it in a cookie requires CSRF protections. Storing it in JavaScript memory means it’s lost on page refresh.
Test for: JWTs stored in localStorage (grab them with DevTools and check if they can be used from another browser), missing token expiration (decode the JWT at jwt.io and check the exp claim), algorithm confusion attacks (change alg to none or HS256 when the server expects RS256), and tokens that contain excessive user data (PII in the payload that any JavaScript on the page can read). See Auth0’s token storage guidance for the defensive side.
SPAs that embed iframes or communicate with popups (OAuth flows, payment widgets, chat integrations) use window.postMessage(). The vulnerability occurs when the receiving window doesn’t validate the message origin.
Test for: missing event.origin checks in message handlers, sensitive data sent via postMessage without origin restrictions, and message handlers that execute code or modify the DOM based on untrusted message content.
Prototype pollution is a JavaScript-specific attack where an attacker modifies Object.prototype, causing every object in the application to inherit the polluted properties. In SPAs, this can lead to XSS, privilege escalation, or denial of service.
Common entry points: URL query parameters parsed with vulnerable libraries (qs, lodash’s merge, jQuery’s $.extend), JSON input that’s deeply merged without sanitization, and state management actions that merge user input into the application store.
Test by injecting __proto__[polluted]=true or constructor[prototype][polluted]=true into merge-able inputs and checking if ({}).polluted returns true in the console.
AI pentesting solves the SPA coverage problem by doing what traditional scanners can’t: interacting with the application through a real browser, the same way a human tester would. Strobes AI agents use Browser Bridge to drive an actual browser instance, clicking buttons, filling forms, navigating menus, and waiting for async content to load.
This matters because a SPA’s attack surface is only visible through interaction. You can’t discover that a dropdown menu loads a list of user accounts via API call unless you actually click the dropdown. You can’t find that a multi-step form wizard has an IDOR on step 3 unless you complete steps 1 and 2 first. Traditional crawlers don’t do this. The Strobes AI agent does.
The Browser Bridge runs a real browser (Chrome) either in the cloud or on your local machine via the Local Browser Bridge. The AI agent controls this browser the way a human tester would: it reads the page, identifies interactive elements, clicks them, types into input fields, and observes the results. For SPAs, this means:
<a> tags from HTML; it interacts with the router by clicking navigation elements.While Browser Bridge handles the client-side, Agent Shell gives the AI agent terminal access to run tools like sqlmap, nuclei, and custom scripts against the backend API endpoints discovered during the browser-based crawl. The combination of browser-based UI testing and shell-based API testing covers both attack surfaces. Learn more about automated pentesting with Strobes.
Supervisor Mode lets you choose how much autonomy the AI agent has. For a first-time SPA pentest, use User mode: the agent pauses before each major action and waits for your approval. You can see exactly what it’s testing, modify its approach if needed, or reject actions that aren’t appropriate for your environment. Once you’re comfortable with the agent’s behavior, switch to Auto mode for fully autonomous runs.
For production SPAs, start with User mode. Approve each action for the first 10-15 steps to validate the agent’s approach, then switch to Auto.
See How Browser Bridge Discovers Every Route Your Scanner Misses
You just read how the Strobes AI agent uses Browser Bridge to click through SPAs, map client-side routes, and test authenticated flows — everything traditional crawlers fail at. Watch it test a real SPA end-to-end.
Full SPA testing walkthrough — no signup required.
SPAs change frequently. Every sprint ships new components, new routes, and new API endpoints. A one-time pentest goes stale within weeks. Strobes Schedules let you run the same pentest weekly or monthly, with automatic diff reports showing what’s new, what’s fixed, and what’s still open. For a SPA that deploys weekly, a weekly scheduled pentest ensures new code gets tested before the next deployment cycle buries it.
Use this as a quick reference during your next SPA engagement.
| Test Area | What to Check | Tools |
|---|---|---|
| Client-side routing | Direct URL access to restricted routes, lazy-loaded module paths | Browser, Burp Repeater |
| JavaScript bundles | API keys, internal URLs, hardcoded credentials, source maps | Chrome DevTools, Semgrep, trufflehog |
| Client-side storage | Tokens in localStorage, sensitive data in sessionStorage/IndexedDB | Chrome DevTools (Application tab) |
| DOM XSS | URL parameters to innerHTML sinks, hash fragment injection | Manual testing, Burp DOM Invader |
| API authorization | Token swapping between roles, IDOR on API endpoints | Burp Suite (Repeater, Autorize extension) |
| JWT handling | Token storage, expiration, algorithm confusion, excessive claims | jwt.io, Burp JWT extension |
| postMessage | Missing origin validation, sensitive data in messages | Console listener, manual review |
| Prototype pollution | __proto__ injection in query params, merge operations | Manual injection, browser console |
| Third-party libraries | Known CVEs in bundled dependencies | Retire.js, Snyk, npm audit |
| Webpack/build config | Source maps exposed in production, debug mode enabled | Browser Network tab, sourceMappingURL check |
ZAP’s default spider can’t handle SPAs because it doesn’t execute JavaScript. ZAP’s AJAX Spider, which uses a headless browser, performs better but still relies on heuristic clicking rather than understanding the application’s navigation structure. For basic coverage, configure the AJAX Spider with authentication and let it run, but expect to supplement with manual proxy-based testing for anything beyond surface-level findings.
No. You can pentest a SPA entirely as a black-box exercise using browser DevTools, a proxy, and the JavaScript bundles the application serves to your browser. Source code access (or source maps) makes the job faster, especially for finding hardcoded secrets and tracing DOM XSS data flows, but it isn’t required.
localStorage is vulnerable to XSS because any JavaScript running on the page can read it. If your application has even one XSS vulnerability, tokens in localStorage are compromised. The more secure alternative is httpOnly cookies with SameSite attributes, which JavaScript can’t access. That said, some SPA architectures make cookies impractical (cross-origin API calls, for example), so the risk comes down to how confident you are in your XSS prevention.
The Strobes AI agent authenticates through the actual login form using credentials stored in the Credentials Vault. It fills in the username and password fields, clicks the login button, waits for the SPA to store the session token, and then maintains that authenticated state throughout the test. For multi-factor authentication, you can use the Local Browser Bridge on a machine where the MFA device (like a YubiKey) is available.
Testing only the API and ignoring the client-side code. Many teams proxy the SPA’s API calls and test those endpoints for SQLi, IDOR, and auth bypass (which is necessary), but skip reviewing the JavaScript bundle for secrets, the client-side routing for authorization gaps, and the DOM for XSS sinks. The client-side code is part of the attack surface. Treat it that way.
At minimum, run a test before every major release and quarterly for compliance. If you’re deploying weekly (most SPA teams are), continuous testing is the only approach that keeps pace. A scheduled weekly pentest catches new vulnerabilities within days of introduction instead of letting them sit in production for months until the next annual assessment.