Remove unused API endpoints safely: code search and logs

How to remove unused API endpoints with evidence: code search across every repo, weeks of nginx access logs with zero requests, then proof buyers still work.

By

Every API has routes nobody calls anymore: an old version of a screen, a feature that got redesigned, an admin helper from a launch two years ago. They still run, they still accept requests, and they still have to be secure. Deciding to remove unused API endpoints is easy. Doing it without breaking a client you forgot about takes evidence. This is the process I follow, from two recent cleanups: three image routes on a photo store I run, and four dead handlers on a fitness backend.

Why an unused endpoint is still attack surface

On the photo store, three image routes were open to any logged-in account. Logged in is a low bar for anything that hands out files.

The routes did check purchases before handing out original files. But one of them was a list route that returned every image in the store. Any private photo added later would have shown up in that list for anyone with an account.

That is the real cost of an unused endpoint. Nobody tests it, nobody reviews it when the data model changes, and its assumptions quietly go stale while it keeps answering requests. Each one is a piece of attack surface you pay for and get nothing back from. The OWASP API Security Top 10 lists this as improper inventory management, with old endpoints left running under weaker security as a common way in. The three routes have since been removed.

Search every repo that could call it

The first piece of evidence is the code. I search for the route path and the handler name in every repository that could possibly call the API. For the photo store, that meant both repos, frontend and backend. Neither had any reference to the three routes.

"Every repository" is the part people shortcut. The list I go through:

  • The web frontend, including server-side code that calls the API.
  • Mobile apps, including old versions still installed on phones. A route the current app no longer calls can still be hit by a build from last year.
  • Scripts, cron jobs and admin tools.
  • Anything outside your code: partners, integrations, webhooks you registered with a third party.

Search for the path as a string, and also for how it might be built. A client that assembles '/images/' + id will not match a search for the full path.

Code search tells you who could call a route. It does not tell you whether anyone does.

Count real requests in the nginx access logs

The second piece of evidence is traffic. On the photo store, the nginx access logs showed zero requests to those routes for weeks. That, together with zero references in code, is what I want before removing something.

With the default combined log format, the request path is the seventh field, so a quick count across current and rotated logs looks like this:

zgrep -h -E '"(GET|POST|PUT|PATCH|DELETE) /api/images' /var/log/nginx/access.log* \
  | awk '{print $7}' | sort | uniq -c | sort -rn

zgrep reads both the plain current log and the gzipped rotated ones. Two things to check before trusting an empty result. First, how far back your logs go: if log rotation keeps a week, "no requests" covers a week. Look at the oldest file's dates. Second, whether the format is what you think it is. If someone customised log_format, the path may be in a different field. The nginx log module documentation shows the combined format and how custom ones are defined.

If your API sits behind a load balancer or CDN, check those logs too, or your application's own request metrics. The point is to count requests at a layer every client has to pass through.

How long is long enough depends on the route. Something a mobile app calls on launch shows up within a day. A monthly report or a yearly renewal flow needs a longer window, so I match the window to the slowest plausible caller.

Remove unused API endpoints in stages when evidence is thin

When code search and logs both come back empty for weeks, I remove the route. That is what happened with the photo store.

When the evidence is weaker, I stage it. My default sequence:

  1. Log a warning with the caller's details whenever the route is hit.
  2. After a quiet period, return 410 Gone instead of the old response, so any forgotten client fails clearly.
  3. Delete the code once that has also been quiet.

On the fitness backend the situation was simpler. Four handlers existed that nothing routed to at all, because the routes run their V2 versions. Unreachable code needs no traffic check, since no request can get to it. I commented the four handlers out, along with the imports only they used. Commenting out is the softer step. Git history keeps the code either way, so deleting outright is just as valid if your team prefers it.

Dead handlers like those are also worth catching before they pile up. AI coding assistants are good at generating plausible extra code, and a review habit of asking "what calls this?" is part of reviewing AI-assisted code without lowering the bar.

Verify the real path still works

Removing a route is a change like any other, and it needs a check that the things people depend on still work. For the photo store, the important flow was downloads, so I checked it from both sides after the removal. A real buyer still gets a working signed download link through the real route. A non-buyer is refused.

Both halves matter. The first proves you removed the right thing. The second proves the access check you rely on is the one still standing. I like checks that exercise the real system over reading the diff, for the same reason I used a canary build in keeping test API keys out of production builds.

Is dead code a security risk if nothing calls it?

Unreachable code, like a handler no route points to, is mostly a maintenance cost: it confuses readers and can be wired back in by mistake. Reachable code that nobody uses is a real security risk, because an attacker does not need your frontend to call a route. Anything that answers HTTP requests is part of your attack surface whether or not your own apps use it.

The short version

  • Treat every route that answers requests as attack surface, used or not.
  • Get two kinds of evidence before removing one: no references in any repo, and no requests in the logs over a window that fits the route.
  • Stage the removal when the evidence is thin, and verify the real flow from both the allowed and the refused side afterwards.

I go through this kind of cleanup whenever I review a backend, along with checks like securing a purchase webhook with a shared secret. If you want that done on yours, see my services.

Want this kind of work on your product?

Get in touch