How to Tell If Your Web App Is Under Attack (and What to Do)

Failed-login spikes, 404 bursts, one IP trying many usernames: how to tell if your web app is under attack, confirm it in your logs, and act in hour one.

Oct 26, 2026·9 min read

How to Tell If Your Web App Is Under Attack (and What to Do)

You can usually tell from data you already have: status codes, routes and client IPs. An attack shows up as a pattern normal users don't produce, such as a spike of failed logins, one IP trying many usernames, bursts of 404s on paths you never built, or one client walking through sequential IDs. The work is confirming the pattern, containing it with reversible measures, and then fixing whatever made the attack worth attempting.

The fields you need first

Every check below depends on a few fields per request. If one is missing, adding it is your first fix.

FieldWhy it mattersCommon mistake
Real client IPGroups requests by sourceLogging the load balancer or CDN address
Route template and raw path/api/invoices/:id to group, the raw path to see which IDLogging only one of the two
Status codeSeparates failures (401, 403, 404, 429) from successesErrors returned as 200 with an error body
User ID or attempted usernameTells credential stuffing apart from targeted brute forceNot recorded on failed logins
User agentSpots scripts and copy-pasted browser stringsDropped or truncated

Add a timestamp and response size (useful for scraping). Hash attempted usernames if you'd rather not store them in plain text.

Behind a proxy, the client IP comes from X-Forwarded-For, and only the entry your own proxy appended can be trusted. In Express, set app.set('trust proxy', 1) for exactly one proxy hop and read req.ip. If every row shows the same handful of addresses, you're looking at your infrastructure, not your users.

The SQL below assumes those fields in a requests table, in Postgres syntax. The same logic works in ClickHouse, BigQuery or your log tool's query language.

Signal 1: a spike of failed logins

The most common attack on small apps is credential stuffing: username and password pairs leaked from other sites, replayed against your login. Volume climbing while the success rate collapses is the giveaway.

SELECT date_trunc('minute', ts) AS minute,
       count(*) FILTER (WHERE status = 200)         AS ok,
       count(*) FILTER (WHERE status IN (401, 403)) AS failed
FROM requests
WHERE route = '/api/auth/login'
  AND ts > now() - interval '6 hours'
GROUP BY 1
ORDER BY 1;

Then find the shape, because it decides the response:

-- Many usernames from one IP
SELECT client_ip, count(*) AS failures, count(DISTINCT username) AS usernames
FROM requests
WHERE route = '/api/auth/login' AND status = 401
  AND ts > now() - interval '1 hour'
GROUP BY client_ip
ORDER BY failures DESC
LIMIT 20;

-- One username from many IPs
SELECT username, count(DISTINCT client_ip) AS ips, count(*) AS failures
FROM requests
WHERE route = '/api/auth/login' AND status = 401
  AND ts > now() - interval '1 hour'
GROUP BY username
HAVING count(DISTINCT client_ip) >= 5
ORDER BY ips DESC;
PatternLikely attackFirst response
Many usernames, one IPCredential stuffing from a single sourceBlock or rate-limit that IP
One username, many IPsTargeted brute force through proxiesProtect the account: lockout, MFA, tell the owner
Many usernames, many IPs, each low volumeDistributed stuffing via residential proxiesRoute-level rate limit, challenge, ASN rules
Failures, then a success from the same IPPossible account takeoverRevoke that session, force a password reset

The single-IP case can be automated in a few lines of middleware; see detecting credential stuffing in Express.

Signal 2: bursts of 404s on routes you never built

Requests for /.env, /wp-login.php and /.git/config come from scanners. They are mostly noise, and this guide explains what each one wants and how to confirm nothing is exposed. The useful query finds the scanners that got something other than a 404:

SELECT client_ip,
       count(*) FILTER (WHERE status = 404) AS not_found,
       count(*) FILTER (WHERE status < 400) AS succeeded
FROM requests
WHERE ts > now() - interval '24 hours'
GROUP BY client_ip
HAVING count(*) FILTER (WHERE status = 404) > 50
ORDER BY succeeded DESC, not_found DESC;

An IP with hundreds of 404s and a handful of successes is worth reading row by row.

Signal 3: one client walking through IDs

Sequential or high-volume access to /:id routes is enumeration: scraping, or someone testing whether your authorization holds.

SELECT coalesce(user_id::text, client_ip::text) AS actor,
       count(DISTINCT path) AS distinct_ids,
       count(*) FILTER (WHERE status IN (403, 404)) AS denied
FROM requests
WHERE route = '/api/invoices/:id'
  AND ts > now() - interval '1 hour'
GROUP BY 1
ORDER BY distinct_ids DESC
LIMIT 20;

A real user opens a few invoices. Hundreds of distinct IDs with many denials is probing. Hundreds of distinct IDs with no denials is either a legitimate bulk export or a missing authorization check, and you want to know which today.

Signal 4: traffic that doesn't look like your users

Compare against a normal week:

  • Login or signup traffic from hosting providers (AWS, DigitalOcean, Hetzner, OVH) when your users are on home and mobile networks.
  • User agents such as python-requests, curl, Go-http-client or headless Chrome, or one exact browser version shared by hundreds of IPs.
  • Countries you don't serve, at hours your users sleep. Weak on its own (travel, VPNs), meaningful combined with the signals above.

Turning an IP into its network owner (ASN) needs an IP-intelligence lookup; the Express IP firewall guide shows where that fits in a Node app.

Signal 5: errors or 429s piling up on one endpoint

  • A 500 spike on one route often means someone is sending input it never expected: injection strings in search, oversized JSON, malformed IDs. Read the parameters of the failing requests, not just the stack trace.
  • A 400 spike with strange payloads (' OR 1=1, ../, <script>, ${jndi:) is someone fuzzing that endpoint.
  • A 429 spike means your rate limiter is working. Check who it's hitting: one IP is an attacker or a broken client; an office or large customer behind one NAT means the limit is too low. Rate limiting that actually works covers per-user keys and proxy setup.

Signal 6: the bill moves first

Some attacks surface in billing before anyone reads a log: egress from scraping, function invocations from bots, SMS charges from OTP abuse, tokens from someone using your LLM feature as a free API. If a usage graph jumps without a matching jump in signups or revenue, group that endpoint's traffic by client IP and user. The $3,400 egress bill post-mortem is a worked example.

Signal 7: account changes right after an unusual login

A successful login from an IP or network the account has never used, followed within minutes by an email change, password change, new API key or new payout details, is the pattern of account takeover. Log those changes with the session and client IP that made them, so you can connect the two.

The first hour

Order matters: contain with measures you can undo, keep the evidence, then fix the cause.

1. Contain, reversibly.

  • Tighten the rate limit on the targeted route: login, signup, OTP or the scraped endpoint.
  • Block the top offending IPs with an expiry, such as 24 hours. Attackers rotate addresses, and permanent lists collect false positives.
  • If the traffic comes from a hosting network your real users don't use, block or challenge that ASN on the targeted route only.
  • Revoke sessions for any account that logged in successfully from an attacking IP, and require a password reset.
  • If an abused feature can't be protected quickly (open signups, a free LLM endpoint), put it behind a flag and switch it off.

2. Preserve evidence. Export logs for the whole window, starting from the first suspicious request, before retention rotates them. Write down when you noticed and every change you made. If you suspect a server is compromised, snapshot it before redeploying over it.

3. Measure the blast radius. Every successful response to an attacking IP is something they got: a login, a record, a file. That list decides whether this was an attempt or a breach.

SELECT ts, client_ip, user_id, method, path, status
FROM requests
WHERE client_ip IN ('203.0.113.7', '198.51.100.23')
  AND status < 400
ORDER BY ts;

4. Fix the root cause: the missing rate limit, the missing ownership check, the leaked key. Then lift temporary measures one at a time while rerunning the same queries.

5. Tell the people affected. If accounts were taken over or personal data was accessed, users need to know, and you may have legal deadlines, such as GDPR's 72-hour window for notifying the supervisory authority when a breach puts people at risk.

Stop flying blind

Every query above assumes the data exists, is structured, and carries the real client IP. For many small teams, the first attack is how they discover it doesn't. Three things fix that:

  1. Structured request telemetry with the fields from the table at the top, retained for at least a couple of weeks.
  2. Alerts on patterns, not single events: failed logins per IP, distinct usernames per IP, distinct IDs per actor, 404s per IP, login success rate. Set thresholds from your own normal week.
  3. Response controls you can undo: blocks with expiries, per-route rate limits, session revocation.

You can build parts of this yourself; blocking bot traffic in Express is a good place to start.

SecureNow packages the loop for Node.js and Next.js teams. One npm package records each request as a trace with the real client IP, route and status, and the patterns above can run as alert rules. Through the MCP server, your coding agent can investigate (securenow forensics "<query>" or securenow ip <address> from the CLI), propose a response and execute it once you approve. Blocks can carry an expiry (securenow blocklist add <ip> --duration 24h --reason "..."), unblocking keeps the history, and sessions can be revoked when your app uses SecureNow's session integration. Detect, investigate, propose, approve, execute, and reverse when needed.

Next step

If you suspect something right now, run the Signal 1 queries and the blast-radius query first. To see these signals for your own app without writing the plumbing, SecureNow has a 14-day trial with no credit card.

Frequently Asked Questions

How do I know if my website is being attacked?

Look for patterns real users don't produce: spikes of failed logins, one IP trying many usernames, bursts of 404s on paths you never built, one client requesting hundreds of different IDs, or a usage bill jumping without matching growth. Seeing them requires request logs with the real client IP, route, status and user.

What are the signs of a credential stuffing attack?

A sharp rise in failed logins while the success rate drops, many different usernames tried from one IP or from many low-volume IPs, and scripted user agents or hosting-provider IPs on your login route. The few successes mixed into the failures are the accounts that were actually compromised.

What does a sudden spike in 401 or 429 responses mean?

A 401 spike on your login route usually means password guessing or credential stuffing. A 429 spike means your rate limiter is triggering; check whether it is catching one abusive client or blocking legitimate users who share an IP address.

What should I do first if my app is under attack?

Contain it with reversible measures: tighten the rate limit on the targeted route, block the top offending IPs with an expiry, and revoke sessions that logged in from attacking IPs. Then export the logs before they rotate and review every successful response the attackers received.

Should I block the attacking IP addresses?

Yes, temporarily. Block with an expiry such as 24 hours, because attackers rotate addresses and permanent lists collect false positives. For distributed attacks, per-route rate limits and network-level (ASN) rules on the targeted route work better than chasing single IPs.

Recommended reading

Why Is My App Getting /.env and wp-login.php Requests?

Those /.env, /.git and wp-login.php hits are bots hunting leaked secrets. See which matter, check you expose nothing, and what to do if one returns 200.

Oct 26
OWASP API Security Top 10 (2023): A Developer's Guide

Each OWASP API Security Top 10 (2023) risk explained for developers: what it means, how to spot it in your code, and a free AI prompt to audit your API.

Jul 17
A Next.js AWS App That Investigates and Blocks an Attack

We deployed a Next.js app to AWS with SecureNow traces, logs, body capture and firewall, then used an AI-assisted MCP workflow to block the attacker's IP.

May 18