Most web applications do not fail because of one rare, cinematic exploit. They fail because a predictable trust boundary was handled carelessly: user input was rendered, a query was concatenated, an authorization check was skipped, or a browser-side cookie became the wrong source of truth. Once those boundaries blur, the same small set of threats shows up again and again.
If you build React or Next.js apps, the useful mental model is simple: treat untrusted data as hostile, keep authorization on the server, and assume the browser will faithfully follow its own rules even when that creates risk. The main threats are usually cross-site scripting, injection, broken access control, CSRF, and SSRF or unsafe file uploads.
The short list that matters
XSS: untrusted markup in a trusted DOM
Cross-site scripting happens when attacker-controlled content is rendered as executable HTML or script. In React, the most obvious danger is any path that bypasses the framework's normal escaping rules.
export function CommentBody({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
That code may be legitimate for trusted rich text, but it is dangerous if html can come from a user, a CMS, or a third-party integration. The browser will render whatever is there, including event handlers, inline scripts, or malicious links.
If you need rich text, sanitize it before it reaches the DOM. If you only need text, let React escape it for you.
import DOMPurify from 'isomorphic-dompurify';
export function SafeCommentBody({ html }: { html: string }) {
const safeHtml = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: safeHtml }} />;
}
The better default is still plain text rendering whenever you can avoid HTML entirely.
Injection: the database should see values, not strings
Injection flaws happen when untrusted input is merged into a command instead of being passed as data. SQL injection is the classic example, but the pattern also appears in shell commands, search filters, and NoSQL queries.
const sql = 'SELECT id, email FROM users WHERE email = ' + email;
const result = await db.execute(sql);
That version is unsafe because the query shape changes with the input. A parameterized query keeps the structure fixed and the value separate.
const result = await db.execute({
sql: 'SELECT id, email FROM users WHERE email = ?',
args: [email],
});
The same rule applies outside SQL. If input can affect command syntax, filter syntax, or path resolution, the application is probably one string-concatenation mistake away from trouble.
Broken access control: authentication is not authorization
A user being signed in does not mean they are allowed to access every record. Broken access control is one of the most common web application failures because it often hides in innocent-looking code paths like GET /documents/123 or DELETE /account/9.
The mistake is to check only that a session exists and forget to check ownership or role.
export async function DELETE(request: Request) {
const user = await requireUser(request);
const documentId = new URL(request.url).searchParams.get('id');
const document = await db.execute({
sql: 'SELECT owner_id FROM documents WHERE id = ?',
args: [documentId],
});
if (document.rows.length === 0) {
return new Response('Not found', { status: 404 });
}
if (document.rows[0].owner_id !== user.id && !user.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
await db.execute({
sql: 'DELETE FROM documents WHERE id = ?',
args: [documentId],
});
return new Response(null, { status: 204 });
}
The important part is where the check lives: on the server, right next to the action. Authorization should not be inferred from client state, hidden in the UI, or trusted because a route is hard to guess.
CSRF: cookies are convenient, and that is the problem
If a browser attaches credentials automatically, an attacker can sometimes trigger a state-changing request from another site. That is CSRF: the user did not intend the action, but the browser still sent the authenticated request.
This is why cookie settings matter. A session cookie should be paired with HttpOnly, Secure in production, and an appropriate SameSite mode. For sensitive actions, many apps also add a CSRF token.
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const csrfCookie = request.headers.get('cookie')?.match(/csrf=([^;]+)/)?.[1];
const csrfHeader = request.headers.get('x-csrf-token');
if (!csrfCookie || csrfCookie !== csrfHeader) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json({ ok: true });
}
That is not the only valid pattern, but the principle is the same: browser-managed credentials need a second check before they can change state.
SSRF and unsafe uploads: user input reaches places it should not
Server-side request forgery happens when user input controls where the server makes a request. That can expose internal metadata endpoints, private admin services, or network-only hosts that should never be reachable from the public app.
const allowedHosts = new Set(['api.example.com', 'images.example.com']);
export function assertAllowedUrl(input: string) {
const url = new URL(input);
if (!allowedHosts.has(url.hostname)) {
throw new Error('Blocked host');
}
return url;
}
File uploads have a similar shape. A filename, MIME type, or extension is not enough to trust the file. Validate size, type, and where the file is stored, and never execute uploaded content.
function validateUpload(file: File) {
const allowedTypes = new Set(['image/png', 'image/jpeg']);
if (!allowedTypes.has(file.type)) {
throw new Error('Unsupported file type');
}
if (file.size > 2_000_000) {
throw new Error('File too large');
}
}
These bugs are easy to miss because they often start as convenience features: image fetchers, URL previewers, import tools, or content uploads.
What to fix first
| Threat | Typical mistake | First defense |
|---|---|---|
| XSS | Rendering untrusted HTML | Escape by default, sanitize rich text |
| Injection | Building commands with strings | Parameterize and whitelist |
| Broken access control | Trusting the logged-in user alone | Check ownership and role on the server |
| CSRF | Assuming cookies are enough | SameSite, CSRF tokens, origin checks |
| SSRF and uploads | Trusting URLs and files from users | Allowlists, size limits, type checks |
If you only fix one thing, start with the path that handles secrets or privileged actions. That is usually where the worst compromise begins.
The main threats for a web application are not mysterious. They are the predictable places where untrusted input crosses into a trusted boundary: the browser DOM, a query string, a permission check, a cookie-backed request, or an outbound network call. The safest apps are not the ones that assume those boundaries are harmless. They are the ones that keep every boundary explicit.