Web Development
Web Security: Prevent XSS, SQL Injection & API Abuse
Practical web security controls for XSS, SQL injection, broken API authorization, HTTPS, and vulnerable dependencies—with code examples and production trade-offs.
At 09:17 on a Monday, your SaaS support queue starts filling up. Customers report unexpected account activity. A few can't open their invoices. An admin spots unfamiliar API requests riding on a legitimate session.
The app is up. Database latency is normal. CPU is flat. No infrastructure failure anywhere.
The weakness is in the application itself: an untrusted comment rendered as executable HTML, an endpoint that trusted a client-supplied resource ID, or a query built by string concatenation. A running application is not necessarily a secure one.
These bugs come from wrong assumptions at boundaries: trusting browser input, assuming a logged-in user can see every record, or believing that encrypted traffic makes an endpoint safe. Each needs its own control. HTTPS can't fix SQL injection. Parameterized queries can't fix broken authorization. A dependency scanner can't fix unsafe HTML rendering.
Cross-site scripting (XSS): when data becomes code#
XSS happens when an app treats untrusted input as executable content. The attacker's script then runs in your origin, where it can act as the victim, read page data, and rewrite what other users see. An HttpOnly cookie stops script from reading the session cookie, but it doesn't stop script from making authenticated requests.
It shows up three ways. Stored XSS is saved (a comment, a review) and served to others. Reflected XSS bounces a request parameter straight back into the HTML. DOM-based XSS lives in browser code, like this:
const name = new URLSearchParams(location.search).get("name");
document.getElementById("welcome").innerHTML = name; // untrusted value parsed as HTML
Keep framework escaping on#
React escapes interpolated text by default. Don't bypass it:
<p>{comment.body}</p> // safe
<div dangerouslySetInnerHTML={{ __html: comment.body }} /> // bypasses escaping
When you genuinely need user-authored HTML, sanitize it with a maintained library such as DOMPurify, and keep it patched:
import DOMPurify from "dompurify";
const safeHtml = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
return <article dangerouslySetInnerHTML={{ __html: safeHtml }} />;
Server-rendered apps need a supported server-side sanitizer setup. For plain DOM updates, use textContent instead of innerHTML, and validate URL schemes on user-supplied links, since javascript: is a valid URL.
Add a Content Security Policy#
CSP restricts what the browser will load or execute, so it can blunt an XSS bug you missed. Start in report-only mode:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_PER_RESPONSE}';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
This is illustrative, not drop-in. The nonce must be random per response and attached only to trusted scripts. Fix the violations you see, then enforce. Don't "solve" breakage by adding unsafe-inline to script-src.
Trade-off: strict CSP is awkward with inline scripts, third-party widgets, and cached server-rendered HTML. It's defense in depth, never a replacement for safe rendering.
SQL injection: when input changes the query#
SQL injection is a query-construction problem, not a "bad characters" problem. Concatenate untrusted values into SQL and the database can't tell your structure from the attacker's input:
String sql = "SELECT * FROM app_user WHERE email = '" + email + "'";
ORMs don't make you immune. Native queries and dynamically built JPQL can inject too.
Use parameterized queries#
String sql = "SELECT id, email FROM app_user WHERE email = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, email);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) { /* use rs */ }
}
}
The point isn't the question mark. It's that structure and values travel separately.
Allowlist dynamic structure#
Placeholders bind values, not column names or keywords, so ORDER BY ? doesn't work. Map user choices to identifiers you own:
String orderBy = switch (sortField) {
case "displayName" -> "display_name";
case "createdAt" -> "created_at";
default -> "created_at";
};
String direction = "asc".equalsIgnoreCase(sortDirection) ? "ASC" : "DESC";
String sql = "SELECT id, display_name FROM app_user ORDER BY %s %s LIMIT ?"
.formatted(orderBy, direction); // identifiers from a fixed map; page size stays bound
Also stop connecting as a root or admin account. Give the app only the permissions it needs (SELECT, INSERT, limited UPDATE) and use separate credentials for migrations. That limits the damage when something else fails.
API authorization: authentication is not permission#
A valid token proves who the caller is. It doesn't prove they may read this invoice. If the backend does this:
invoiceRepository.findById(invoiceId);
then any logged-in customer who changes INV-10042 to another ID may get a different tenant's invoice. That's Broken Object Level Authorization (BOLA), and it's brutal in multi-tenant systems.
Enforce ownership where data is fetched#
Take the tenant from the server-side principal, never from the request body:
public Invoice getInvoice(UUID invoiceId, AuthenticatedActor actor) {
return invoiceRepository
.findByIdAndTenantId(invoiceId, actor.tenantId())
.orElseThrow(NotFoundException::new);
}
SELECT * FROM invoice WHERE id = ? AND tenant_id = ?;
Stop mass assignment#
Binding client JSON straight onto an entity lets someone send "role": "ADMIN" or another tenantId. Use request DTOs with only the permitted fields:
public record UpdateProfileRequest(String displayName, String timezone) {}
A valid JSON body is not an authorized database update. A central auth filter helps, but every sensitive endpoint still needs its own resource-level check. In Spring, @PreAuthorize can add enforcement if method security is on and the rules match real ownership.
Control resource consumption#
Correct auth doesn't stop abuse. Set per-user and per-tenant rate limits, request-size caps, maximum page sizes, and timeouts for expensive operations. Use a distributed limiter (a token bucket in shared storage) when you run several replicas, since process-local counters can't enforce a global limit. Return 429 Too Many Requests with Retry-After. And keep an inventory of endpoints, because forgotten admin routes and old API versions are still attack surface. The OWASP API Security Top 10 is a good audit framework.
HTTPS and TLS: protect the connection#
TLS gives you encryption, server authentication, and integrity in transit. It does nothing about whether a user may see a given invoice. An encrypted unauthorized request is still unauthorized.
Redirect HTTP to HTTPS and send the basic headers (fragment only; certificates and upstreams omitted):
server {
listen 80;
server_name api.example.com;
return 308 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Make the app trust X-Forwarded-Proto only from your known proxies. Turn on HSTS only after HTTPS works everywhere it will apply, and treat includeSubDomains and preload as deliberate decisions, because browsers remember them and reversal is slow.
Set session cookies with sensible attributes:
Set-Cookie: __Host-session=<value>; Path=/; Secure; HttpOnly; SameSite=Lax
Secure limits the cookie to HTTPS, HttpOnly blocks ordinary JS access, and SameSite limits some cross-site sending. None of that replaces CSRF protection for cookie-based auth.
Dependencies: you inherit other people's bugs#
A frontend can pull in hundreds of transitive npm packages, and a Spring service plenty of Maven layers. When one is vulnerable, you are too, though whether it's actually exploitable depends on reachability, deployment, and permissions.
Automate discovery. A minimal Dependabot config:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "weekly"
Add OWASP Dependency-Check or a similar SCA tool as a second opinion. Treat upgrades as controlled changes: confirm the fixed version, check whether the vulnerable code is reachable, update lockfiles, run tests and scans, then verify the deployed artifact. Prioritize actively exploited and remotely reachable issues over raw CVSS scores. A WAF rule or a disabled feature can buy time, but it isn't a substitute for the patch. Keep manifests and lockfiles in version control, and generate an SBOM for important production artifacts so you can answer "are we affected?" fast.
Trade-offs and release checks#
Security controls cost latency, flexibility, and deploy complexity. Teams that pretend otherwise end up switching controls off during an outage.
| Control | Cost | Practical response |
|---|---|---|
| HTML sanitization | Extra processing, fewer rich-content features | Sanitize only what must render as HTML |
| Strict CSP | Breaks inline scripts and some third parties | Report-only first, then nonces or hashes |
| Tenant-scoped queries | Extra filtering on every read | Index ownership fields; check query plans |
| Rate limiting | Throttles legitimate bursts | Per-endpoint, tenant-aware quotas |
| Dependency updates | Regressions | Staged CI/CD rollout with tests |
| HTTPS and HSTS | Certificate and proxy upkeep | Automate renewal and add deploy checks |
On that tenant row: adding tenant_id to every query is a small code change that can become a table scan at scale. Check the schema, and consider a composite index on (tenant_id, id) if your existing keys don't already cover it. Correct indexing is what makes secure data isolation practical.
Then test the boundaries, not just the happy path. Use separate test accounts in separate tenants to prove User A can't read User B's records. Don't test authorization with an admin account only, since it hides what ordinary users hit. Confirm that untrusted comments render as text in the actual browser, that HTTP redirects to HTTPS, that expired tokens are rejected, and that excess traffic gets a 429. No scanner proves an app is secure, so pair automated checks with tests that encode your real access rules.
Frequently asked questions#
Does HTTPS prevent XSS and SQL injection?#
No. HTTPS protects data in transit. XSS and SQL injection are application flaws that need application-level defenses.
Are React apps automatically safe from XSS?#
React escapes interpolated text by default. dangerouslySetInnerHTML, unsafe URL handling, and raw DOM manipulation can still reintroduce XSS.
Do prepared statements stop every injection?#
They protect bound values. They don't cover dynamically built identifiers, unsafe stored-procedure internals, shell commands, or other injection contexts.
Is a JWT enough to secure a REST API?#
No. A verified JWT tells you who the caller is. You still need to authorize each operation and check access to the specific resource.
Does CORS protect my API from unauthorized requests?#
No. CORS controls which browser origins may read cross-origin responses. It is not authentication or authorization, so enforce access control on the server.
Official references#
- OWASP XSS Prevention Cheat Sheet
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP API Security Top 10
- OWASP Content Security Policy Cheat Sheet
- NIST Secure Software Development Framework (SP 800-218)
The next security incident probably won't look like an outage. It may look like one ordinary API request, a dependency update that never shipped, or a customer comment your frontend rendered without question.
When you're on call, start with the affected request path. Identify the principal, the resource it touched, the code that processed its input, and the exact deployed dependency versions. Preserve logs before you change anything. Revoke compromised sessions, contain the endpoint, and verify the real production artifact after patching.
Don't close the incident when the service responds again. Close it when the vulnerable path is fixed, the exploit has been retested, and the same failure can't slip back in with the next release.