Your attack surface is everything an attacker can reach and interact with. The bigger it is, the more chances they get, and a modern web app has a much larger surface than most of the people building it realise. It includes every HTTP endpoint, every client-side route, every file upload handler, every third-party integration, every dependency in your package.json, and every environment variable, secret, and config value you ship.
It helps to walk the surface in layers, because each one fails in its own way.
Network edge
DNS subdomain takeover, TLS misconfiguration, requests that bypass the CDN and hit your origin directly, gaps in your WAF rules.
HTTP layer
Routes, including the debug endpoint someone forgot to remove. Methods, like a handler that's meant to be GET-only quietly answering DELETE. Headers, where Host injection and loose CORS live. Cookies, and whether they actually carry HttpOnly, Secure, and SameSite. Then the params and bodies that carry the actual payloads.
Auth and sessions
Login is exposed to brute force, credential stuffing, and timing attacks. Password reset leaks through predictable tokens and account enumeration. OAuth gets attacked through the redirect URI, a missing state parameter, and referrer leakage. Sessions fail through fixation, weak expiry, and where you store the token.
APIs
REST endpoints, GraphQL (introspection left on, nested-query abuse, missing per-field auth), and the gRPC and WebSocket surfaces that get far less scrutiny than REST. And the internal API that skips auth entirely because "nothing external can reach it," right up until something can.
Client-side
Your JavaScript bundle runs on the attacker's machine, so treat all of it as readable. Source maps in production hand over your code. API keys in client code are public keys. Add DOM-based XSS, open redirects, an insecure postMessage handler, and tokens parked in localStorage where any XSS can read them.
Data storage
Injection into SQL, NoSQL, or the ORM. Public S3 buckets and pre-signed URLs with no expiry. An unauthenticated Redis or Memcached on an internal IP. Sensitive data sitting in plaintext in your logs.
Third-party dependencies
Every npm and PyPI package is code you didn't write but run with full trust. Every SaaS integration is an API key and a webhook. Every external <script> tag is code execution inside your origin.
Infrastructure and CI/CD
Container images carrying CVEs, secrets in environment variables, pull-request injection into your pipeline, IAM roles scoped far too broadly, a Kubernetes dashboard exposed to the internet.
Shrinking the surface
You can't defend what you don't need, so the cheapest control is usually removal.
- Remove what you don't use. Dead endpoints, unused dependencies, leftover debug routes.
- Restrict what stays. Not every endpoint needs to be public. Segment the network, require auth, enforce authorisation.
- Constrain what each thing accepts. A JSON-only endpoint should reject everything that isn't JSON.
- Watch what's left. Keep an inventory of your endpoints, dependencies, and integrations so the surface doesn't grow behind your back.
Try this on your own app
Pick one feature. Draw the path its data takes from the user's browser to the database and back. At every hop, write down four things: what protocol is in use, who can reach this component, what input it accepts, and what an attacker could do at this exact point.
You will almost certainly find something you hadn't accounted for. Map the surface, shrink it where you can, and keep an eye on whatever you decide to leave exposed.
