Fullstack security: for each piece of data, which layer holds the line
Whoever writes both the frontend and the backend usually makes one precise mistake: putting a defence in the wrong layer. They validate a field in the form and not on the server, hide a "Delete" button and do not check the owner of the object, trust a CSP and stop encoding output. The rule that holds everything together is only one: the browser belongs to the user, so what comes from the browser is decided by whoever is attacking; every check that matters is done on the server, and the headers the server sends to the browser are the second net, not the first. This article tests the rule on a project built for the purpose, in two versions: one vulnerable and one defended. Five attacks, all against my test application run in-process, with no open ports and no network, each one paired with the countermeasure and a check that verifies it. In-depth treatments of each topic are in the security articles linked along the text; the other articles in this series are on the series page.
The map: which risks, in which layer
The OWASP Top 10:2025 (owasp.org/Top10/2025, consulted on 2026-11-13) is the reference list, and it has a virtue for those who work fullstack: its categories can be assigned to a layer. Broken access control (A01) includes, among other things, access to other people's data by changing an identifier, and CSRF; injection (A05) includes SQL injection and XSS; security misconfiguration (A02) includes, among other things, the server that does not send the security headers; authentication failures (A07) are the subject of the next article. The A01 page lists as typical scenarios precisely access to someone else's account by supplying their identifier and APIs without controls on POST, PUT and DELETE; the A05 page indicates XSS and SQL injection as two faces of the same category.
- The server decides who you are and which objects you can access: ownership of objects, modifiable fields, origin of the requests that change state.
- The server turns data into queries and HTML only through parameters and encoding, never by concatenating strings.
- The browser applies the rules the server declares to it with headers: CSP,
nosniff, HSTS.
The three lines are not in order of importance but of growing trust in the client: in the first the browser has no role, in the second a passive one (it receives what the server has already made harmless), in the third it collaborates, but only if it obeys. An application that has the third line and not the first is ready for the days when an old browser or a command-line script ignores the headers. That is why the test that follows concentrates on the server.
The target: a single application, two versions
The test application has five paths: search in your own notes, reading a note, editing the profile, creating a note and comments. The defended flag chooses, in each path, between the vulnerable code and the corrected one, so the difference is in the same place and can be read side by side. Two declared simplifications: the session is an in-memory map from cookie to user (how authentication is really built is the subject of Authentication: JWT, OAuth2, session vs token), and the database is node:sqlite in memory, a module that the Node 24 documentation classifies as "release candidate" (stability 1.2), that is, not yet stable. The versions used are Node 24.11.0 and Fastify 5.12.8. The code identifiers are in English, but the data values are the example's own: ada and bob are two users, and the note titles are groceries, wifi-password and draft.
import Fastify from 'fastify';
import { DatabaseSync } from 'node:sqlite';
import { protect } from './headers.mjs';
import { originAllowed } from './origin.mjs';
const ORIGIN = 'https://app.example';
const sessions = new Map([['s-ada', 1], ['s-bob', 2]]);
const esc = (t) => String(t).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
export function build({ defended }) {
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT);
CREATE TABLE notes (id INTEGER PRIMARY KEY, owner INTEGER, title TEXT);
CREATE TABLE comments (id INTEGER PRIMARY KEY, text TEXT);
INSERT INTO users VALUES (1,'ada','user'),(2,'bob','user');
INSERT INTO notes VALUES (1,1,'groceries'),(2,2,'wifi-password'),(3,2,'draft');`);
const app = Fastify();
if (defended) protect(app);
app.addHook('preHandler', async (req, reply) => {
req.uid = sessions.get((req.headers.cookie ?? '').replace('sid=', ''));
if (!req.uid) return reply.code(401).send({ error: 'not authenticated' });
if (defended && !originAllowed(req, ORIGIN)) return reply.code(403).send({ error: 'origin not allowed' });
});
app.get('/search', async (req) => defended
? db.prepare('SELECT title FROM notes WHERE owner = ? AND title = ?').all(req.uid, req.query.q)
: db.prepare(`SELECT title FROM notes WHERE owner = ${req.uid} AND title = '${req.query.q}'`).all());
app.get('/notes/:id', async (req, reply) => {
const r = defended
? db.prepare('SELECT * FROM notes WHERE id = ? AND owner = ?').get(req.params.id, req.uid)
: db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
return r ?? reply.code(404).send({ error: 'not found' });
});
app.post('/profile', async (req) => {
const allowed = defended ? ['name'].filter((c) => c in req.body) : Object.keys(req.body);
for (const field of allowed) db.prepare(`UPDATE users SET ${field} = ? WHERE id = ?`).run(req.body[field], req.uid);
return db.prepare('SELECT name, role FROM users WHERE id = ?').get(req.uid);
});
app.post('/notes', async (req, reply) => {
db.prepare('INSERT INTO notes (owner, title) VALUES (?, ?)').run(req.uid, String(req.body.title));
return reply.code(201).send({ ok: true });
});
app.post('/comments', async (req, reply) => {
db.prepare('INSERT INTO comments (text) VALUES (?)').run(String(req.body.text));
return reply.code(201).send({ ok: true });
});
app.get('/comments', async (req, reply) => {
const rows = db.prepare('SELECT text FROM comments').all().map((c) => `<li>${defended ? esc(c.text) : c.text}</li>`);
return reply.type('text/html').send(`<ul>${rows.join('')}</ul><script nonce="${req.nonce ?? ''}">/* app */</script>`);
});
return { app, db };
}
The four lines that matter are those where the defended branch and the vulnerable branch diverge. In the search the defended version passes req.uid and req.query.q as parameters of prepare, while the vulnerable one pastes them inside the SQL string. In reading the note the defended version adds AND owner = ?: if the note exists but is not yours, the response is identical to that of a non-existent note (404), so not even the existence of the identifier leaks. In the profile the defended version admits only the name field, while the vulnerable one applies every key that arrives, role included: this is the mass assignment described by the OWASP Mass Assignment Cheat Sheet. In the comments the defended version encodes the five special HTML characters before inserting them. Everything else is common.
The code also has a defect that none of the attacks below shows: the defended branch does not validate the format of the incoming data, so a request to /search without the q parameter produces a 500 error (ERR_INVALID_ARG_TYPE, tried separately) instead of a clean 400. The validation schema at the edge, with Ajv and its surprising behaviours, is covered in SQL Injection and Input Validation.
The two defences that live in the headers
The first concerns CSRF. A malicious site can make the user's browser fire a POST request to your application, and the browser attaches the session cookie. The modern browser, however, declares where the request comes from with the Sec-Fetch-Site header, which can be same-origin, same-site, cross-site or none (MDN, Sec-Fetch-Site). The OWASP CSRF cheat sheet points to it as a lightweight method for blocking evidently cross-site requests, and adds that a fallback on checking Origin is needed for browsers that do not send it (OWASP, CSRF Prevention). The function below does exactly this, and lets read-only methods through.
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// CSRF defence without a token: Fetch Metadata, falling back to the Origin header.
export function originAllowed(req, ownOrigin) {
if (SAFE_METHODS.has(req.method)) return true;
const site = req.headers['sec-fetch-site'];
if (site) return site === 'same-origin' || site === 'none';
const origin = req.headers.origin;
return origin === undefined || origin === ownOrigin;
}
When Sec-Fetch-Site is present, the decision stops there: only requests from your own site or started by the user (none) are admitted; same-site is excluded on purpose, because another subdomain is not trusted as much as yours. If it is missing, Origin is checked. The case with neither is let through: according to MDN browsers add Origin to cross-origin requests and to same-origin ones other than GET and HEAD (with some exceptions), so the absence of both on a POST almost always indicates a non-browser client, which does not attach cookies by itself. An Origin: null, instead, is rejected, because it differs from yours (MDN, Origin). If your application behaves differently, change that line. This function does not replace SameSite on the cookie nor an anti-CSRF token where they are needed: the article XSS, CSRF and CSP explains how they combine.
The second defence is the set of headers on the responses. The CSP with a nonce tells the browser to run only the scripts that carry the secret value of that response; X-Content-Type-Options: nosniff prevents a response with a type different from the expected one from being run as script or style (MDN, X-Content-Type-Options); HSTS says for how many seconds the browser must remember to use only HTTPS with that host (MDN, Strict-Transport-Security).
import { randomBytes } from 'node:crypto';
// A new nonce for every response: the browser runs only the scripts that carry it.
export function protect(app) {
app.addHook('onRequest', async (req) => {
req.nonce = randomBytes(16).toString('base64');
});
app.addHook('onSend', async (req, reply, payload) => {
reply.header('Content-Security-Policy',
`default-src 'self'; script-src 'nonce-${req.nonce}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`);
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Strict-Transport-Security', 'max-age=31536000');
return payload;
});
}
The nonce is generated in onRequest with 16 random bytes, and ends up both in the header and (in the comments path) in the nonce attribute of the script tag: if the script carries the same value as the header, the browser runs it; a script injected by an attacker cannot know it, because it changes at every response. The value max-age=31536000 equals 365 days (365 times 86,400 seconds); I did not add includeSubDomains, because with that option the rule applies to all subdomains as well, and a choice like that must be made knowingly. As MDN reminds us, a CSP is not an alternative to encoding input: they are two defences in depth, which is why the test that follows keeps them separate.
Five attacks and two checks
The test program builds, for each attack, a new vulnerable application and a new defended one, runs the same request on both with inject() and reports what happened. Two rows are checks: a legitimate search and a POST from the same site, to show that the defences do not break normal use. Where possible the outcome is not read from the HTTP response but from the database, a second independent channel: the role is read with a query after the attack, and so is the number of notes after the cross-site POST.
import { build } from './app.mjs';
const ada = { cookie: 'sid=s-ada' };
const json = { ...ada, 'content-type': 'application/json' };
const results = [];
async function attack(name, run) {
const row = { name };
for (const [key, defended] of [['vulnerable', false], ['defended', true]]) {
const { app, db } = build({ defended });
row[key] = await run(app, db);
}
results.push(row);
}
await attack('SQL injection on /search', async (app) => {
const r = await app.inject({ url: '/search?q=' + encodeURIComponent("x' OR '1'='1"), headers: ada });
return `${r.json().length} titles returned`;
});
await attack('Check: search "groceries"', async (app) => {
const r = await app.inject({ url: '/search?q=groceries', headers: ada });
return `${r.json().length} title returned`;
});
await attack('Someone else\'s note /notes/2', async (app) => {
const r = await app.inject({ url: '/notes/2', headers: ada });
return r.statusCode === 200 ? `read: ${r.json().title}` : `denied (${r.statusCode})`;
});
await attack('Role field in /profile', async (app, db) => {
await app.inject({ method: 'POST', url: '/profile', headers: json, payload: { name: 'ada', role: 'admin' } });
return `role in the DB: ${db.prepare('SELECT role FROM users WHERE id = 1').get().role}`;
});
await attack('POST from another site', async (app, db) => {
const h = { ...json, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' };
const r = await app.inject({ method: 'POST', url: '/notes', headers: h, payload: { title: 'inserted from outside' } });
return `${r.statusCode}, ada's notes: ${db.prepare('SELECT COUNT(*) AS n FROM notes WHERE owner = 1').get().n}`;
});
await attack('Check: same-site POST', async (app) => {
const h = { ...json, origin: 'https://app.example', 'sec-fetch-site': 'same-origin' };
const r = await app.inject({ method: 'POST', url: '/notes', headers: h, payload: { title: 'mine' } });
return `${r.statusCode}`;
});
await attack('Comment with <script>', async (app) => {
await app.inject({ method: 'POST', url: '/comments', headers: json, payload: { text: '<script>alert(1)</script>' } });
const html = (await app.inject({ url: '/comments', headers: ada })).body;
return html.includes('<script>alert') ? 'script tag in the text' : 'script rendered inert';
});
for (const r of results) console.log(`${r.name.padEnd(30)} | ${r.vulnerable.padEnd(28)} | ${r.defended}`);
const { app } = build({ defended: true });
const a = (await app.inject({ url: '/comments', headers: ada })).headers;
const b = (await app.inject({ url: '/comments', headers: ada })).headers;
console.log('CSP different on every response:', a['content-security-policy'] !== b['content-security-policy']);
console.log('nosniff:', a['x-content-type-options'], '| HSTS:', a['strict-transport-security']);
// Output:
// SQL injection on /search | 3 titles returned | 0 titles returned
// Check: search "groceries" | 1 title returned | 1 title returned
// Someone else's note /notes/2 | read: wifi-password | denied (404)
// Role field in /profile | role in the DB: admin | role in the DB: user
// POST from another site | 201, ada's notes: 2 | 403, ada's notes: 1
// Check: same-site POST | 201 | 201
// Comment with <script> | script tag in the text | script rendered inert
// CSP different on every response: true
// nosniff: nosniff | HSTS: max-age=31536000
The table says that the SQL injection returns all three notes of both users to the vulnerable version and none to the defended one, while the legitimate search returns one to both. It says that Bob's note is readable by Ada only without the filter by owner, and that the role field becomes admin in the database only without the allowlist. The POST with Sec-Fetch-Site: cross-site creates a note (201, two notes) without the defence and is rejected (403, one note) with the defence, while the POST from the same site passes in both cases. The comment with a script tag ends up in the HTML as it is in the vulnerable version and is encoded in the defended one. The last two lines verify that the nonce changes at every response and that the other headers are present. The output is identical across two consecutive runs.
What this test does not prove
No browser was started. The test shows that the server rejects, encodes and declares; it does not show that a browser really blocks a script without a nonce, nor that it sends Sec-Fetch-Site in the way described, because that would require a real browser. It does not cover real authentication (cookies with HttpOnly, Secure, SameSite; session rotation), for which I refer you to Secure Authentication: Session Management and Cookies, nor CORS and object-level authorization in depth, covered in API Security: OAuth 2.1, JWT Best Practices and Rate Limiting. It does not cover the supply chain and dependencies, nor logs. And it is not a penetration test: each attack is a single chosen case, which demonstrates a class of defect, not its absence in your code.
What you can do now
You can assign an OWASP Top 10:2025 risk to the layer that must stop it, and say which defence lives in the server (parameters, owner check, field allowlist, origin check, output encoding) and which in the browser on the server's orders (CSP with nonce, nosniff, HSTS). You can write for each one a test that attacks your application with the wrong request and verifies the outcome in the database, not only in the response. To go deeper one topic at a time start from OWASP Top 10 2025: The Complete Developer Guide; the next article in the series, Authentication, builds the part that here was simulated with a map, and the previous article, Core Web Vitals, showed a public endpoint that collects data from visitors and must be validated for the same reason.
Frequently asked questions
Is a CSP enough to be protected from XSS?
No. MDN says so explicitly: CSP is not an alternative to input encoding and sanitization, but a defence in depth. In the test, output encoding is what makes the comment inert; the CSP remains the second net.
Why return 404 and not 403 for another user's note?
Because with the 403 you confirm that the identifier exists. The defended version filters by owner already in the query, so someone else's note is indistinguishable from a note that is not there.
Does the Sec-Fetch-Site check replace the anti-CSRF token?
Not for every case. The OWASP cheat sheet presents it as a lightweight method for blocking the most evident cross-site requests, with a fallback on Origin for browsers that do not send it. Where a stronger defence is needed, or you have requests that do not come from a modern browser, the token remains.
The last article in the series, Multi-environment CI/CD, proposes running these checks as tests on every push.
Sources
- OWASP Top 10:2025 - list and names of the ten categories, consulted on 2026-11-13
- A01:2025 Broken Access Control - scenarios of access through an identifier and CWE-352 (CSRF) among the mapped CWEs, consulted on 2026-11-13
- A05:2025 Injection - XSS (CWE-79) and SQL injection (CWE-89) in the same category, consulted on 2026-11-13
- OWASP, Cross-Site Request Forgery Prevention Cheat Sheet - Fetch Metadata as a lightweight method and fallback on the Origin header, consulted on 2026-11-13
- OWASP, Mass Assignment Cheat Sheet - definition and solution with an allowlist, consulted on 2026-11-13
- Content Security Policy (MDN) - nonces for scripts and CSP as defence in depth, not an alternative to sanitization, consulted on 2026-11-13
- Sec-Fetch-Site (MDN) - the four values of the header, consulted on 2026-11-13
- Origin (MDN) - when browsers add the header and the exceptions, consulted on 2026-11-13
- Strict-Transport-Security (MDN) - meaning of max-age and includeSubDomains, consulted on 2026-11-13
- X-Content-Type-Options (MDN) - effect of nosniff on scripts and styles, consulted on 2026-11-13
- Node.js v24, node:sqlite - stability 1.2 (release candidate) and prepared statements with parameters, consulted on 2026-11-13
All the listings were run with Node 24.11.0 and Fastify 5.12.8 through inject(), twice with identical output; no port opened, no network, no file of other projects analysed. The attacks are against a test application built for this article. No browser was started: the behaviour of CSP and Sec-Fetch-Site in real browsers is the one described by MDN and OWASP, not verified here.
Commenti
Caricamento commenti...
Sign in to comment