22 hand-picked Web Security interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
What is XSS and how do you prevent it?
Cross-Site Scripting: attacker-controlled data is interpreted as code in a victim's browser, running with your site's origin — so it can read cookies (unless HttpOnly), call your APIs as the user, and rewrite the page.
Three types:
- Stored — the payload is persisted (a comment, a profile field) and served to every viewer. Most damaging.
- Reflected — the payload comes from the request (a query param echoed into the page) and needs a crafted link.
- DOM-based — never touches the server: client JS takes a value from
location.hash/localStorage and writes it into a dangerous sink.
Prevention: escape on output, contextually (HTML, attribute, URL and JS contexts each need different escaping). Modern frameworks escape interpolated values by default — the vulnerabilities live in the escape hatches: innerHTML, dangerouslySetInnerHTML, [innerHTML], v-html, and building URLs from user input. Sanitise any HTML you must render with a maintained library (DOMPurify), and layer a strict CSP as defence in depth.
// dangerous sinks — every XSS bug in a modern app is near one of these
el.innerHTML = userInput; // ✗
el.outerHTML = userInput; // ✗
document.write(userInput); // ✗
eval(userInput); new Function(userInput); // ✗
location.href = userInput; // ✗ javascript: URLs
// safe
el.textContent = userInput; // ✓ never parsed as HTML
el.innerHTML = DOMPurify.sanitize(userHtml); // ✓ when HTML is required
How does Content Security Policy work, and how do you deploy one?
A response header telling the browser which sources are allowed to load and execute. Even if an XSS payload lands, a good CSP stops it running — it's the main defence-in-depth layer for XSS.
The important part is script control. An allowlist of domains is weak (one CDN hosting a vulnerable library or JSONP endpoint bypasses it). The modern approach is nonce- or hash-based with strict-dynamic: only scripts carrying the per-response nonce run, and scripts they load inherit trust.
Content-Security-Policy:
script-src 'nonce-{random}' 'strict-dynamic' https:;
object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Deployment: ship Content-Security-Policy-Report-Only with a report-uri/report-to first, collect violations for a few weeks, fix inline scripts and styles, then enforce. Going straight to enforce breaks production.
What is CSRF and what actually prevents it?
Cross-Site Request Forgery: a malicious site causes the victim's browser to send an authenticated request to yours. It works because cookies are attached automatically by origin, so the server can't tell the request wasn't initiated by your UI. The attacker never reads the response — they only need the side effect (transfer money, change email).
Defences:
SameSite cookies — Lax (now the browser default) blocks cross-site POSTs; Strict blocks even top-level GET navigation. Strong, but not a complete substitute: it doesn't protect against same-site attackers (a vulnerable subdomain) and behaviour varies.- Anti-CSRF tokens — a per-session/per-request token the attacker can't read due to the same-origin policy. Synchroniser token (server-stored) or double-submit cookie.
- Origin/Referer validation on state-changing requests.
- Custom header requirement — a header like
X-Requested-With forces a CORS preflight, which a cross-site form can't produce.
Note that token-in-header auth (Authorization: Bearer) is not CSRF-prone — nothing is sent automatically. CSRF is fundamentally a cookie-auth problem.
What does CORS actually do, and what are the common mistakes?
The Same-Origin Policy blocks a page from reading responses from another origin. CORS is the server's way of opting in — it does not add security, it relaxes it in a controlled way.
Simple vs preflighted: GET/POST/HEAD with a small set of "safe" headers and content types go straight through (the response is just hidden if not allowed). Anything else — PUT, DELETE, Content-Type: application/json, custom headers — triggers an OPTIONS preflight first.
Mistakes that matter:
Access-Control-Allow-Origin: * together with credentials — the browser rejects it, and reflecting the caller's Origin instead effectively allows everyone.- Thinking CORS protects the API. It doesn't: the request still reaches your server, and non-browser clients ignore CORS entirely. Authentication and authorisation are what protect the API.
- Forgetting
Vary: Origin, which lets a CDN cache one origin's CORS response for another. - Not handling the preflight (returning 401 for
OPTIONS, which carries no credentials).
Where should you store auth tokens in a browser?
The honest answer is a trade-off between two attack classes, not a single winner:
- localStorage / sessionStorage — readable by any JavaScript on the page, so any XSS steals the token. No CSRF exposure (nothing is sent automatically). Convenient for calling APIs on other origins.
- HttpOnly, Secure, SameSite cookie — invisible to JavaScript, so XSS can't read it (though it can still use the session by making requests). Introduces CSRF exposure, which SameSite plus tokens handles.
The generally recommended shape: refresh token in an HttpOnly; Secure; SameSite=Strict cookie scoped to the auth endpoint, short-lived access token held in memory only (a JS variable, not storage), refreshed silently. XSS can then only steal a token that expires in minutes, and nothing survives a tab close.
Whatever you choose: keep tokens short-lived, support revocation server-side, and remember that if you have XSS you have a compromise regardless of storage.
How should a SPA implement OAuth 2 login?
Authorization Code flow with PKCE — the only currently recommended flow for public clients (SPAs and mobile apps).
- The client generates a random
code_verifier and sends its SHA-256 hash as code_challenge with the authorize request. - The user authenticates; the provider redirects back with a one-time
code. - The client exchanges the code plus the original verifier for tokens.
PKCE exists because a SPA cannot hold a client secret — anything in the bundle is public. Without it, an attacker who intercepts the redirect (a malicious app registered for the same URI, a leaked referrer) could redeem the code. With it, the code is useless without the verifier that never left the client.
Also required: the state parameter (CSRF protection on the callback), nonce for OIDC ID tokens, exact-match redirect URIs, and validating the ID token signature, issuer, audience and expiry. The implicit flow is deprecated — it returned tokens in the URL fragment, where they leak into history, logs and referrers.
What is clickjacking and how do you defend against it?
The attacker loads your page in a transparent iframe over their own UI, so the victim thinks they're clicking "Play video" while actually clicking "Delete account" or "Approve payment" on your site — with their session attached.
Defence: tell the browser who may frame you.
Content-Security-Policy: frame-ancestors 'none' (or an explicit allowlist) — the modern control, and the one that supports multiple origins.X-Frame-Options: DENY | SAMEORIGIN — the legacy header; still worth sending for old browsers.
Frame-busting JavaScript is not a defence — sandbox on the attacker's iframe disables scripts.
Related: for genuinely sensitive actions add a second factor the overlay can't fake — re-authentication, a typed confirmation, or a randomly positioned confirm step.
Why can't you keep a secret in frontend code?
Everything shipped to the browser is readable: bundles, source maps, environment variables inlined at build time, and every network request in DevTools. Minification is not obfuscation, and obfuscation is not protection.
So these are all public: API keys baked into the bundle, NEXT_PUBLIC_*/VITE_* variables (the prefix literally means "exposed"), values fetched from a config endpoint, and anything in a JWT payload.
What to do instead:
- Keep secrets on a server and proxy the call — the browser talks to your backend, your backend holds the key.
- For keys that must be public (Maps, analytics, Firebase web config), restrict them at the provider: HTTP referrer allowlists, per-key scopes, quotas, and server-side rules.
- Enforce authorisation server-side — hiding a button is UX, not access control.
Is client-side validation a security control?
No. It's a user-experience feature — instant feedback, fewer round trips, clearer forms. An attacker simply uses curl, Postman, or DevTools to edit the request, and none of your JavaScript runs.
The server must independently validate: types and formats, ranges and lengths, allowed enum values, business rules (can this user perform this action on this resource?), and object ownership.
Two related traps:
- Mass assignment — binding the whole request body to a model lets a caller set
role: "admin". Use explicit allowlists of writable fields. - Hidden or disabled fields — a disabled input or a hidden price field is fully editable by the client. Never trust a value just because your UI didn't offer a way to change it.
Where it's practical, share one schema (Zod/Yup/JSON Schema) between client and server so the rules can't drift — but the server still enforces.
You must render user-supplied HTML. How do you do it safely?
Escaping isn't an option (you need the markup to render), so you must sanitize — parse the HTML and remove anything dangerous — with a maintained library, never a regex or blocklist.
DOMPurify is the standard choice. Configure an allowlist of tags and attributes rather than trying to enumerate what's bad, and remember the attack surface is wider than <script>: event handler attributes (onerror, onload), javascript: URLs in href/src, <iframe>, <object>, SVG (which can carry script), and CSS expressions.
Where to sanitize: on output/render, not only on input — sanitising on save leaves any pre-existing stored data unsafe and breaks if the rules change. Doing both is fine; doing only the input side is the common mistake.
Extra layers: a strict CSP so anything that slips through can't execute, and Trusted Types (Chromium) which makes assigning a raw string to innerHTML a runtime error unless it passed a policy.
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p','b','i','em','strong','a','ul','ol','li','code','pre','br'],
ALLOWED_ATTR: ['href','title'],
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i // blocks javascript: and data:
});
el.innerHTML = clean;
What is an open redirect and why does it matter?
Your app takes a URL from a parameter (?returnUrl=, ?next=) and redirects to it without validation. An attacker sends https://yourbank.com/login?next=https://evil.com — the link genuinely starts on your trusted domain, so it survives user scrutiny, email filters and link scanners, then lands the victim on a phishing page.
It's also a component in bigger attacks: OAuth token theft via a manipulated redirect URI, and bypassing allowlists in SSRF chains.
Prevention:
- Prefer relative paths only — reject anything with a scheme or host.
- If absolute URLs are genuinely needed, use a strict allowlist of hosts.
- Better still, don't put the destination in the URL: store it server-side against the session, or map an opaque key to a known route.
- Watch the bypass tricks:
//evil.com (protocol-relative), https:/\evil.com, encoded characters, and @-in-authority tricks. Parse with the URL API and compare the resolved origin rather than string-matching.
What are the security rules for postMessage and iframes?
postMessage deliberately crosses the origin boundary, so both ends must do the checking the browser no longer does for you.
Sending: always specify the exact target origin. iframe.contentWindow.postMessage(data, '*') broadcasts to whatever document is loaded there — which may not be the one you think after a redirect.
Receiving: validate event.origin against an allowlist first, before touching event.data; then validate the shape of the data. A handler that does eval(event.data) or writes it into innerHTML is an XSS entry point from any site that can open a window to yours.
Embedding untrusted content: use sandbox and grant back only what's needed. sandbox="allow-scripts allow-same-origin" together defeats the point — the frame can reach into its own origin and remove the sandbox.
Being embedded: control it with CSP frame-ancestors.
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted.example.com') return; // FIRST
const msg = event.data;
if (typeof msg !== 'object' || msg.type !== 'RESIZE') return; // then shape
setHeight(Number(msg.height) || 0);
});
Session cookies vs token-based auth — which do you choose?
Server sessions (cookie holds an opaque id): state lives server-side, so revocation is instant and the cookie reveals nothing. Needs a shared session store when you scale horizontally, and it's cookie-based so CSRF applies. Excellent default for a first-party web app.
Tokens (JWT in a header): stateless verification — any service can validate the signature without a lookup, which suits microservices, mobile clients and third-party APIs. Costs you revocation (valid until expiry) and puts the token where JavaScript can reach it unless you use cookies anyway.
How to decide: a first-party web app with one backend → sessions. Multiple services, mobile apps or external API consumers → tokens, with short expiry plus refresh rotation. Many teams end up with the BFF pattern: the browser gets a session cookie, the backend holds tokens and talks to downstream services.
What is the Same-Origin Policy?
The browser's foundational isolation rule: a document from one origin cannot read data from another. Origin = scheme + host + port, all three exactly matching — https://app.com and https://api.app.com are different origins, as are http:// and https:// versions of the same host.
What it blocks: reading another origin's DOM (via an iframe or window handle), reading fetch/XHR responses, and reading cross-origin canvas pixel data or detailed script errors.
What it does not block: sending requests. Embedding cross-origin images, scripts, stylesheets and form submissions has always been allowed — which is precisely why CSRF exists.
Controlled escapes: CORS (server opts in to being read), postMessage (explicit messaging), document.domain (deprecated), and JSONP (legacy, avoid).