The Developer's Quarterly · Web SecurityVol. VI · April 2026

Main Security Threats:What Actually Breaks Web Apps First

The biggest web application breaches usually come from predictable trust mistakes: unescaped output, unsafe queries, broken authorization, weak CSRF defenses, and risky outbound requests.

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 Attacker-controlled markup or script runs in the browser.
Injection Strings become commands, queries, or filters instead of staying data.
Broken access control A signed-in user reaches data or actions they do not own.
CSRF Browser-managed credentials get used for requests the user did not mean to send.
SSRF and uploads User input reaches internal systems, file storage, or execution paths it should never touch.

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.

Unsafe pattern
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.

Safer pattern
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.

Unsafe query
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.

Parameterized query
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.

Server-side ownership check
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.

Token check in a route handler
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.

URL allowlist
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.

Upload validation
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.