A deep walk-through of web security for JS developers: same-origin policy, CORS, XSS, CSRF, clickjacking, CSP, secrets, supply chain, plus 2026 case studies from axios and Vercel. Built as an interview-ready reference for anyone who (like me) has to re-learn this every few months.

Every time I start preparing for an interview, I end up on the same loop:

So this post is partly for you, if you're preparing for a frontend interview and want one place to review the common security questions. And it's partly for future me, because I'm tired of re-learning this from scratch every six months.
Picture this.
A developer is building a small app with the help of an AI coding assistant. They need to test an API call, so they paste an API key into the chat "just to see if it works." The AI, being helpful, generates code that uses the key directly, like this:

No warning. No .env file. If that code ships, the key lands in the frontend bundle, on GitHub, on npm, in a CDN. Within hours, bots will have found it.
AI assistants are productive. They're also faster than your ability to review what they produce, which is how mistakes slip through. "Review your code carefully" is still the right advice; it's just harder to follow when the next diff arrives in thirty seconds.
In April 2026, Vercel disclosed that a compromised third-party AI tool was the entry point for an attack that exposed plaintext environment variables belonging to a subset of their customers. The AI layer in your workflow is now part of your threat model. More on that in the case studies.
The rest of this post walks through the major web security topics you'll hit in interviews, on the job, and on the day you wake up to a $2,000 API bill you didn't spend.
Before we talk about attacks, we need to talk about the rules the browser already enforces. Most interview questions on web security start here.
The Same-Origin Policy is the rule underneath almost everything else the browser does for security. It says:
A script from origin A cannot read responses, cookies, or storage from origin B.
An origin is not the whole URL. It's just three parts of it: the scheme, the host, and the port.
Two URLs share an origin only if all three of scheme, host, and port match exactly. The path (/posts, /about) and the query string are not part of the origin at all, so they don't matter.
Take https://blog.com/posts as our starting URL. A quick note on the port: when you write https://blog.com with no port, the browser uses 443 by default (that's the default port for HTTPS). So the origin is really https://blog.com:443.
Now compare that against a few other URLs:
https://blog.com/about β β
same origin. Only the path changed, and path is not part of the origin.https://blog.com:8080/posts β β different origin. The port is now 8080 instead of the default 443.http://blog.com/posts β β different origin. The scheme is http, not https.https://api.blog.com/posts β β different origin. The host is api.blog.com, not blog.com. Even a subdomain counts as a different host.Any one of those three parts being different is enough. That's when SOP kicks in and the browser stops your script from reading the other origin's data.
Without SOP, a tab you opened on evil.com could read your Gmail and your bank balance. It's the reason the web isn't permanently on fire.
It's also why you hit "CORS errors" ten seconds into your first frontend project. Which brings us toβ¦
CORS (Cross-Origin Resource Sharing) is a server-controlled way of relaxing the Same-Origin Policy.
If your frontend at https://app.example.com wants to call your API at https://api.example.com, SOP blocks it by default. CORS lets the API say, "actually, it's okay, I trust this origin."
The server does this via response headers:
For any "non-simple" request (e.g. POST with JSON, or with an Authorization header), the browser first sends a preflight OPTIONS request to check if it's allowed. Only if that succeeds does the real request go through.

Common interview questions:
Why do I get a CORS error even when my server returns a 200?
"Because the browser checks the response headers before letting your JS read the body. The request went through; you just can't see the response."
What's a preflight request?
"An
OPTIONSrequest the browser sends first, to ask the server if the real request is allowed."
Is Access-Control-Allow-Origin: * safe?
"Only if the endpoint has no sensitive data and no credentials. And you can't combine
*withAccess-Control-Allow-Credentials: true."
Rules of thumb:
Access-Control-Allow-Origin: * on authenticated endpoints.Origin header without checking it.A Content Security Policy is an HTTP header that tells the browser: "only load scripts, styles, images, and frames from these specific places."
Even if an attacker manages to inject a <script src="https://evil.com/bad.js">, the browser will refuse to load it because evil.com isn't in your script-src list. CSP is the net that catches the XSS you missed.
Key directives to remember:
| Directive | What it restricts |
|---|---|
default-src | Fallback for all resource types |
script-src | Where JS can be loaded from |
style-src | Where CSS can be loaded from |
img-src | Where images can be loaded from |
connect-src | Where fetch/XHR/WebSocket can go |
frame-ancestors | Who can embed you in an iframe (anti-clickjacking) |
Tip: start with Content-Security-Policy-Report-Only in production. It reports violations without blocking them, so you can see what would break before you actually enforce it.
These three show up in almost every frontend interview.
Think of your website as a radio station. Every HTML response you send out is trusted by the listener's browser because it came from your domain. That trust is what lets the scripts on your page read the user's cookies, touch their login session, and manipulate the DOM.
XSS is what happens when an attacker sneaks a script into your broadcast. You didn't write it and you didn't approve it, but the browser can't tell the difference: it arrived on your frequency, so it plays with your voice. That script can read cookies, impersonate the user on your own API, and quietly exfiltrate anything the page can see.
The attack comes in three common flavours. They differ in how the attacker gets their script into your broadcast.
You search for something on a site, and the site politely echoes back "You searched for: {your query}" on the results page. If it echoes your query as raw HTML, an attacker can craft a URL like this:
They send the URL to a victim (email, DM, social post). The victim clicks. The server echoes the query straight back into the HTML, and the victim's browser runs the script. The malicious code lives only in the URL, never in your database. It's reflected off the server, like an echo.
Now the attacker doesn't bother with a one-off URL. They plant the script permanently in your database. Anywhere you save user input and later show it to other users is a candidate, from the obvious (comment boxes, profile bios) to the forgotten (old support tickets, draft posts an admin reviews in a dashboard).
If someone submits a comment like:
...every visitor who loads that page runs the attacker's script, and a single planted comment can harvest sessions from thousands of readers before anyone notices.
This time the server is innocent. Your own client-side JavaScript takes untrusted input (usually from the URL) and writes it straight into the DOM.
Visiting https://site.com/#<img src=x onerror=alert(1)> is enough. The server never saw the payload. Your own JS in the browser handed the attacker the stage.

Never let untrusted text become HTML or code without escaping it first. Every defense below is an application of that. React's {userInput} is safe because React treats it as text, not HTML. dangerouslySetInnerHTML, innerHTML, document.write, eval, and new Function are all risky because you are explicitly telling the browser "treat this string as code or markup, go run it."
How to defend:
Use the framework features that escape by default (JSX text nodes, Vue's {{ }}, Angular's bindings) and don't override them without a very good reason. Treat dangerouslySetInnerHTML, innerHTML, document.write, eval, and new Function as hazardous. If you need one, you own the responsibility of sanitizing every string that reaches it.
When you actually do need to render user-supplied HTML, pipe it through DOMPurify:
Pair that with a strong CSP so an injection that slips past sanitization still can't load an attacker's script, and set HttpOnly on session cookies so they can't be read from document.cookie if the worst happens.
CSRF tricks an authenticated user's browser into making a state-changing request to your site, without their consent.
The attack relies on two things:
Here's how it plays out. You're logged into bank.com in one tab. In another tab, you visit cute-cats.com. That site contains:
Your browser submits the form to bank.com. Your session cookie goes with it. The bank can't tell this wasn't you.
How to defend:
The browser already does most of the work for you. SameSite=Lax is the modern cookie default, and it stops cookies from being sent on cross-site POSTs. That alone kills the basic form-submit attack. For high-value actions (banking, admin panels) upgrade to SameSite=Strict.
On top of that, two classic defenses:
X-Requested-With: XMLHttpRequest on state-changing fetches. The browser won't let a cross-origin HTML form set custom headers without a preflight, which your API can then reject.| Aspect | XSS | CSRF |
|---|---|---|
| What runs | Attacker's JS in user's browser | No JS of attacker's, just a forged HTTP request |
| What attacker needs | To inject a script into your page | A logged-in user to visit their page |
| Main defense | Input escaping, CSP | SameSite cookies, CSRF tokens |
| Key vulnerability | Trust of data | Trust of authenticated session |
This distinction comes up in most frontend interviews.
Clickjacking is when an attacker loads your site inside a transparent iframe on their page and overlays fake UI on top. The user thinks they're clicking the attacker's button, but the click actually lands on yours, and because they're logged into your site in that tab, the action happens with their real session.
A practical example: social media "likejacking."
An attacker runs a coupons or viral-news page. They embed Twitter's "Follow" button (or a Facebook "Share", or a GitHub "Star") in an iframe at 0% opacity, positioned exactly over an innocent-looking "Play video" button on their own page. A visitor who happens to be logged into Twitter in another tab clicks "Play". The click actually lands on the invisible Follow button, and now they're following an account they've never heard of. Nothing on the page appears to change, because their click was silently consumed by the hidden iframe.
Scaled up, this is how spam accounts inflated their follower counts in the 2010s, and how Facebook users ended up endorsing pages they'd never visited. Anywhere your site lets a logged-in user take an action with a single click (like, follow, authorize an OAuth app, confirm a transfer) is a potential clickjacking target.

How to defend:
Tell the browser: "don't let anyone embed me in an iframe." Two equivalent ways:
The modern, preferred option is a CSP directive:
Or, to allow self-embedding only:
The older X-Frame-Options header still works and is widely supported:
Or SAMEORIGIN. Set one of these on every HTML response. If you don't, any site on the internet can iframe yours.
Bonus defense: mark sensitive cookies as SameSite=Strict, so they're not sent when your page is loaded inside a cross-origin iframe.
These are the issues I see most often in real production code today. They don't always make the classic OWASP top 10 slide decks, but they're where people actually get burned.
Remember the API key scenario from the intro? Here's the lesson in one line:
Every string that ends up in your JavaScript bundle is public to anyone who opens DevTools.
That includes:
NEXT_PUBLIC_, VITE_, or REACT_APP_AI code assistants make this trap easier to fall into. They see a working pattern (const apiKey = "...") and reproduce it. They don't know your deployment pipeline. They don't know what NEXT_PUBLIC_ does. They just pattern-match.
And the AI assistant you write code with isn't the only AI-shaped risk. Any AI tool your team has OAuth-granted into your Google Workspace, Slack, or GitHub is now a potential pivot point, as the Vercel April 2026 incident (covered below in the case studies) demonstrates.
How to defend:
/api/weather on your server; your server talks to the third-party API with the real key.If your backend returns a JWT on login, where do you put it? This is an interview favourite.
| Storage | Readable by JS? | Sent automatically? | Steals via XSS? | CSRF risk? |
|---|---|---|---|---|
localStorage | Yes | No | β Yes | β No |
sessionStorage | Yes | No | β Yes | β No |
| In-memory (JS var) | Yes (in-tab) | No | β Yes | β No |
httpOnly cookie | No | Yes | β No | β οΈ Yes |
Most tutorials show localStorage because it's easy. But any XSS on your site β your user's token is gone.
General guidance:
httpOnly, Secure, SameSite=Lax cookies. That keeps them out of JS, and SameSite handles most of the CSRF.httpOnly cookie. This is the pattern OAuth docs recommend.localStorage. Treat localStorage as readable by any JS on your origin, because that's what it is.Run npm ls on a fresh Next.js app. Scroll. Scroll more. Each package is code that can:
postinstall scripts on your laptop and in CIFamous incidents:
event-stream (2018): a new maintainer added a bitcoin-stealing payloadua-parser-js (2021): compromised maintainer account, cryptominer publishednode-ipc protestware (2022): author pushed a destructive payload for certain IPsaxios (March 2026): maintainer account compromised, versions 1.14.1 and 0.30.4 shipped a RAT via a fake dependency. Live for about 3 hours, ~100M weekly downloads. Full breakdown in the case studies section below.reqeust, lodahs, expresss hoping you fat-finger
How to defend:
npm audit in CI and fail on high severity.npm ci in production builds. It installs exactly what's in the lockfile, where npm install pulls the latest matching version. This one change would have protected most teams from the axios attack.--ignore-scripts when installing packages you don't fully trust.overrides in package.json when the parent is slow to update:
<script src> pointing to a CDN:
A small but important one. In 2026 there's no excuse for running production on HTTP. A few details that still trip people up:
navigator.geolocation, crypto.subtle, camera/mic access) only work over HTTPS. If your feature needs one of these, you must be on HTTPS.On March 31, 2026, attackers published malicious versions of axios to npm: axios@1.14.1 and axios@0.30.4. Axios is a direct or transitive dependency of over 174,000 packages and sees around 100 million weekly downloads. The compromised versions were live for roughly 3 hours before npm removed them.
How the attack worked:
plain-crypto-js@4.2.1. Plausible name, no stars. A classic staging move.plain-crypto-js as a dependency in their package.json. The axios source code itself was untouched.npm install axios during the window, the fake plain-crypto-js was pulled in automatically. Its postinstall hook ran an obfuscated dropper (setup.js) that fetched a platform-specific RAT:
/Library/Caches/com.apple.act.mond, beaconing every 60 seconds~/.ssh and ~/.aws directories: SSH keys and AWS credentials.If you shipped anything between March 31 00:21 UTC and 03:15 UTC:
axios@1.14.0 (or 0.30.3 for legacy), rotate all credentials accessible from the affected machine, and monitor for connections to sfrclak.com / 142.11.206.73./Library/Caches/com.apple.act.mond (macOS) or %TEMP%\6202033.ps1 (Windows).Lessons:
^1.14.0) are the trap door. A fresh npm install happily pulls 1.14.1 if it's the latest match.npm ci would have saved most teams. It only installs versions pinned in the lockfile. If you don't run this in production and CI, fix that today.On April 19, 2026, Vercel disclosed that it had detected unauthorized access to internal systems. Tracing the full attack chain matters here because it's the archetype of modern SaaS risk.
How the attack worked:
What landed in the blast radius:
Vercel's immediate response:
Lessons:
If you've only got 30 minutes before an interview, read this section.
| Attack | What it does | Main defense |
|---|---|---|
| XSS | Runs attacker's JS in your user's browser | Escape output, CSP, avoid innerHTML/eval |
| CSRF | Uses user's cookies to make requests they didn't mean to make | SameSite cookies, CSRF tokens |
| Clickjacking | Tricks user into clicking invisible UI | frame-ancestors 'none', X-Frame-Options |
| Leaked keys | Puts a secret in the client bundle | Proxy via backend, secret scanning |
| Supply chain | Malicious npm package runs on your machine / server | npm ci, Dependabot, --ignore-scripts |
| CORS misuse | Server wide-opens its API to any origin | Specific allowlist, never * with credentials |
| Token theft | XSS reads JWT from localStorage | httpOnly cookies for session tokens |
| MITM | Network attacker reads or rewrites HTTP traffic | HTTPS everywhere, HSTS |
| OAuth pivot | Compromised 3rd-party app escalates into your cloud/SaaS | Audit grants, least-privilege, rotation |
What's the difference between XSS and CSRF?
"XSS runs attacker's code on your site. CSRF makes your browser send a request to the real site using the user's real session."
What's the Same-Origin Policy?
"The browser rule that scripts from one origin can't read responses or storage from another."
Why do I get a CORS error?
"The browser is enforcing SOP; the server hasn't sent headers permitting your origin."
Where should I store a JWT?
"
httpOnlycookie for session tokens.localStorageleaves you one XSS away from a breach."
How do you prevent clickjacking?
"
X-Frame-Options: DENYor CSPframe-ancestors 'none'."
What does SameSite=Lax do?
"It prevents the browser from sending the cookie on cross-site POSTs, and most other top-level navigations from other origins."
What's a CSRF token?
"A random server-issued value required on state-changing requests; unreadable to other origins thanks to SOP."
Tell me about a recent supply chain attack.
"axios, March 2026. The maintainer account was compromised, and a fake dependency
plain-crypto-jspulled in a RAT via its postinstall hook. The fix is to usenpm ciin production, notnpm install."
How would you prevent another Vercel-style incident on your team?
"Audit OAuth grants regularly, mark env vars sensitive by default, use short-lived credentials, and treat AI tools as part of your supply chain."
httpOnly, Secure, SameSite=Lax for session cookies.innerHTML on user data.script-src and frame-ancestors 'none'.npm ci + npm audit in CI.Those eight points will get you through most interviews, and they're honestly how you'd build a secure app from scratch.

Security feels huge until you see it laid out in one place. What it actually comes down to, most of the time, is four habits: ship with safe defaults, read what the AI writes before you commit it, rotate secrets on a schedule, and treat every OAuth grant as a front door. The specifics will keep changing. Those four won't.
Browsers keep adding new primitives. Attackers keep finding new angles. New categories of risk (AI-tool OAuth pivots, prompt injection, model-extraction) are showing up faster than the textbooks can be rewritten. If you understand the pieces in this post, you've at least got the scaffolding to slot the new ones in as they appear.
If you found a mistake (I'll have made some) or want me to dive deeper on any of these, tell me. And if this helped during your interview prep, drop a note. I'll come back and re-read this post myself in a few months when I'm interviewing again.
MDN (the canonical reference):
OWASP:
Case studies referenced in this post:
Tools:
dangerouslySetInnerHTML