Web attacks and defenses every developer should know
Security tends to fail at the seams: validation exists, but not at the boundary; authorization is implemented, but one endpoint is missing it; output is escaped, but one template uses raw HTML. The good news: most real incidents fall into repeatable classes of mistakes—so you can fix them systematically.
Related guides: Observability & monitoring · Databases under load
Contents
- Threat modeling: what you protect and from whom
- Injections: SQLi, command injection, template injection
- XSS: reflected, stored, DOM-based
- CSRF: why “but it’s GET” is not a defense
- Auth & sessions: token theft, fixation, cookies
- Access control & IDOR: “it’s just an id”
- File uploads & path: uploads, traversal, nearby RCE
- SSRF: when your server fetches “the wrong place”
- Unsafe deserialization and object spoofing
- Browser defenses: clickjacking, CORS, headers
- DoS & abuse: rate limits, brute force, expensive work
- Release checklist
Threat modeling: what you protect and from whom
Before techniques—set the frame:
- Attacker: anonymous user, logged-in user, partner with an API key, someone inside the VPN, an actor with CI/CD access.
- Assets: money, PII, accounts, admin panels, integrations, secrets, internal network access.
- Attack surface: forms and APIs, redirects, webhooks, imports/exports, file uploads, third-party integrations (S3, SMTP, payments).
Practical rule: all input is untrusted (including headers, cookies, webhook payloads, URL parameters, queue messages).
Injections: SQLi, command injection, template injection
SQL Injection (SQLi)
SQLi usually starts as “just one raw SQL snippet”. The fix is not “escape”, but parameterize.
Bad (string concatenation):
$rows = DB::select("SELECT * FROM users WHERE email = '{$email}'");
Better (placeholders):
$rows = DB::select('SELECT * FROM users WHERE email = ?', [$email]);
Another common trap is dynamic column names / sorting. Parameters don’t apply to identifiers, so use an allowlist:
$allowed = ['created_at', 'email', 'id'];
$sort = in_array($request->get('sort'), $allowed, true) ? $request->get('sort') : 'created_at';
$users = User::query()->orderBy($sort, 'desc')->paginate();
Command injection
If you call the shell, the rule is: never pass user input into a command line. Even escapeshellarg() is a last-resort guardrail, not a design.
If you must run CLI tooling (convert files, generate previews):
- prefer a library over
exec(); - if CLI is unavoidable, pin binaries/args/dirs and run in a restricted environment (container/queue/worker under a dedicated user).
Template injection
The dangerous case is letting users provide templates that are executed as Blade/Twig/Smarty.
Rule: user templates should be treated as data, ideally rendered through a limited, non–Turing complete format (e.g., Markdown with a strict allowlist and safe rendering).
XSS: reflected, stored, DOM-based
XSS is running JavaScript in your origin. Typical sources:
- raw output (
{!! ... !!}in Blade,innerHTMLon the frontend); - rich-text fields stored without sanitization;
- injecting values into
<script>or HTML attributes without proper encoding.
Core mitigations
- Escape by default: in Blade use
{{ $value }}; avoid raw output unless justified. - Respect contexts: HTML vs attributes vs JS strings vs URLs—different encoding rules.
- Sanitize HTML input: if you allow formatting, use an allowlist (tags/attrs) and strip
on*handlers,javascript:URLs, etc. - CSP: not a logic fix, but a major damage limiter.
Starter CSP (ideally with nonces and without 'unsafe-inline'):
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
CSRF: why “but it’s GET” is not a defense
CSRF happens when a user’s browser sends a request to your site with their cookies/session because another site triggers it (forms, images, JS).
Mitigations
- CSRF tokens for state-changing requests (POST/PUT/PATCH/DELETE). Laravel does this out of the box.
SameSitecookies: at leastLax; considerStrictfor sensitive flows; for cross-site cases useNone; Secure.- Origin/Referer checks for especially sensitive actions (change email, payouts).
- Never change state via GET.
Auth & sessions: token theft, fixation, cookies
What usually breaks:
- weak passwords + missing rate limits → credential stuffing;
- session theft via XSS; session fixation; missing invalidation;
- long-lived tokens without rotation and device binding.
Baseline controls:
- Rate limit login/OTP/password reset.
- Hash passwords with
password_hash()(bcrypt/argon2). No custom crypto. - Session cookies:
HttpOnly,Secure,SameSite, keep lifetime short where you can. - Rotate session IDs after login and privilege changes.
Access control & IDOR: “it’s just an id”
IDOR is when a user can guess/iterate identifiers and read or mutate someone else’s data.
Common causes:
- checking “logged in” instead of ownership/role;
- auth exists in the UI but not on the API;
- “admin” behavior controlled by a request parameter.
Mitigations:
- Policies/gates for each resource and action.
- For multi-tenant apps, always filter by
tenant_id/account_idat the query level. - Don’t rely on “hiding” as security.
- Audit logs for sensitive reads and writes.
Example idea: prefer auth()->user()->orders()->findOrFail($id) over Order::findOrFail($id).
File uploads & path: uploads, traversal, nearby RCE
Uploads are a classic “RCE next door” area.
What to enforce
- validate content, not just extension; browser MIME is untrusted;
- limits on size and count;
- store outside web-root; serve via a controller or a dedicated domain/CDN;
- random filenames (don’t trust original names as paths);
- disable execution in upload directories at the web server level.
Path traversal often appears as “download file by name”:
- reject
../,\, null bytes; - use an allowlist of real files/IDs, not a path from the request.
SSRF: when your server fetches “the wrong place”
SSRF appears when you accept a user URL and fetch it from the server (avatar import, webhook tester, link preview, PDF fetcher).
Risks: access to internal services (cloud metadata endpoints, Redis/Consul, admin panels), bypassing network boundaries.
Mitigations:
- allowlist hosts/domains, not a blacklist;
- block private IP ranges (127.0.0.0/8, 10.0.0.0/8, 169.254.0.0/16, 172.16/12, 192.168/16, ::1, fc00::/7);
- disable redirects or re-validate each hop;
- timeouts, response size limits, protocol restrictions (https only).
Unsafe deserialization and object spoofing
If you deserialize attacker-controlled data (cookies, hidden fields, external queue payloads, unsigned caches), you risk:
- role/field spoofing;
- gadget chains and RCE where applicable.
Rule: don’t store serialized objects in untrusted places. For tokens use signatures (HMAC). For structured payloads use JSON + strict schema + server-side validation.
Browser defenses: clickjacking, CORS, headers
Clickjacking
Block framing:
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
CORS
CORS is not “request blocking”—it’s a browser rule for reading responses. Common mistakes:
Access-Control-Allow-Origin: *with cookies/credentials;- overly broad allowed methods/headers;
- trusting
Originwithout a strict list.
Baseline security headers
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
DoS & abuse: rate limits, brute force, expensive work
In business apps, the most common “mini-DoS” is not bandwidth—it’s cheap-for-them, expensive-for-you endpoints:
- unindexed searches, giant exports, PDF generation;
- missing rate limits (login, email/OTP sending, “promo code check”);
- expensive validation/regexes over huge strings.
Controls:
- rate limiting by IP + account + API key (scenario-dependent);
- queue expensive work;
- timeouts, size limits, JSON depth limits;
- cache heavy public responses (mind personalization).
Release checklist
Input & data
- [ ] Validate at the boundary (forms/APIs/webhooks), normalize types.
- [ ] Allowlist for sorting/filtering/fields that become identifiers.
- [ ] No state changes via GET.
Rendering & browser
- [ ] Escape-by-default, no unjustified raw HTML.
- [ ] CSP/
frame-ancestors, clickjacking protection. - [ ] HSTS,
nosniff, sensibleReferrer-Policy.
Access
- [ ] Server-side authorization for each action (policy/gate), not just UI.
- [ ] No IDOR: queries scoped to owner/tenant.
- [ ] Audit logs for sensitive actions.
Integrations
- [ ] SSRF controls: allowlist, private IP blocks, timeouts/limits.
- [ ] Webhooks: signature verification, replay protection, idempotency.
Uploads
- [ ] Content/size checks, store outside web-root, disable execution.
- [ ] Random names, no user-provided paths.
Summary
If you remember one principle: security is boundary invariants. Validate input once (correctly), escape output by default, enforce authorization on the server, sign external integrations, and rate-limit anything costly.