748,000 Customer Records Sat Behind a URL With No Login. Run These Five Passes on Your Own API

A researcher typed a base path into a browser and got a directory listing of unguarded internal endpoints. The class of bug is OWASP API5, the most boring one there is, and the audit for it fits in an evening.

A security researcher published a writeup on 27 July 2026 describing how he opened Volvo and Eicher's fleet platform at myeicher.in, walked up the API path to /cepauthmgr/user/, and got back a directory listing of internal endpoints that required no authentication at all. Behind them: 748,000 customers, 174,000 users, 676,000 vehicles, 2.5 million one-time passcodes stored since 2021, and 76,000 identity documents including Aadhaar cards and driving licences. Account takeover was available through the OTP and password-reset endpoints. He reported it on 3 November 2025 and the main flaw was patched on 20 November 2025, per his own account (opened 27 July 2026).

There is no clever exploit in that story. The endpoints simply never checked who was calling. OWASP files this as API5:2023 Broken Function Level Authorization (opened 27 July 2026), and its example scenario is a guessed URL, GET /api/admin/v1/users/all, returning every user. If you run a product with an API, the five passes below will tell you tonight whether you have the same problem. Budget two hours.

Pass 0: get the route list, not the route list you remember

Every pass depends on having a complete inventory, and memory is not one. Print the routes from the framework itself:

rails routes
python manage.py show_urls        # django-extensions
curl -s localhost:8000/openapi.json | jq -r '.paths | keys[]'   # FastAPI
npx express-list-endpoints ./app.js

Then compare that list against what is actually reachable from the internet. Routes mounted by a library, a health-check add-on, an admin panel, or an old service you forgot to remove are exactly the ones nobody audits. Also list your subdomains: staging APIs with production databases are a recurring version of this bug.

Pass 1: no credentials at all

Hit every route with no Authorization header, no cookie, no API key. Anything that returns 200 and real data is the finding.

while read path; do
  code=$(curl -s -o /dev/null -w '%{http_code}' "https://api.example.com${path}")
  [ "$code" = "200" ] && echo "OPEN $path"
done < routes.txt

Two traps. First, a 401 on GET does not mean the route is safe: repeat with POST, PUT, PATCH and DELETE, because middleware is sometimes bound to a method rather than a path. Second, a 302 to a login page can still be preceded by a response body, and some frameworks emit the payload before the redirect.

Pass 2: a second tenant's token

Create two accounts in different organisations. Take an object identifier belonging to account A and request it with account B's token. This is API1:2023 Broken Object Level Authorization (opened 27 July 2026), and it is the failure that lets someone read every customer record one identifier at a time. OWASP's own second example scenario is a vehicle manufacturer whose API never checked that a vehicle identification number belonged to the caller, which is close enough to the Eicher case to be uncomfortable.

Run it against every route that takes an identifier, including nested ones. /orders/{id} often gets checked; /orders/{id}/attachments/{fileId} often does not. OWASP's recommendation is blunt: "Implement a proper authorization mechanism that relies on the user policies and hierarchy," and validate permissions "in every function that uses an input from the client to access a record in the database."

Pass 3: walk up the path

This is what found the Eicher listing. Take each route and strip segments one at a time, requesting each prefix:

/api/v1/fleet/vehicles/8842
/api/v1/fleet/vehicles
/api/v1/fleet
/api/v1
/api

You are looking for directory listings, framework debug pages, and index endpoints that enumerate children. Add the common admin guesses while you are there: /admin, /internal, /api/admin, /actuator, /debug, /metrics, /.env, /swagger, /openapi.json. A public OpenAPI document is not itself a vulnerability, but it hands an attacker the inventory you had to generate in Pass 0.

Pass 4: privilege, not just identity

Log in as an ordinary member of an organisation and call the endpoints only an owner or admin should reach: invite creation, role assignment, billing changes, data export, user deletion. OWASP's first API5 scenario is precisely this, an attacker sending POST /api/invites/new with {"email": "[email protected]", "role":"admin"} because the admin route inherited authentication but never checked role. Authentication and authorization are separate checks and are separately forgettable.

Pass 5: make the default deny

The four passes above find today's holes. This one stops tomorrow's, and it is the only structural fix. OWASP states it directly: "The enforcement mechanism(s) should deny all access by default, requiring explicit grants to specific roles for each function."

Django REST Framework is the clearest illustration of why this matters. Its permissions documentation (opened 27 July 2026) says that if you never set DEFAULT_PERMISSION_CLASSES, the framework defaults to rest_framework.permissions.AllowAny. Every view you write is public until you remember otherwise. One block in settings.py inverts that:

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ]
}

The same pattern applies wherever you work, and the shape is always the same: bind the check to the container, not the handler. In Rails, put before_action :authenticate_user! in ApplicationController and use skip_before_action on the two or three genuinely public actions, so opening a route is a visible line in a diff. In FastAPI, attach the dependency at the router level with APIRouter(dependencies=[Depends(current_user)]) rather than decorating each function. In Express, mount the auth middleware on the router before any handler is registered, and keep public routes on a separately mounted router. OWASP's wording for the object-oriented case is to make administrative controllers inherit from an abstract controller that implements the checks.

Then write the tests. OWASP's API1 guidance ends with "Create authorization tests and prevent deploying changes that break them," and this is the part teams skip because it feels like testing the framework. It is not. A three-line test that asserts an unauthenticated request to a protected route returns 401, parameterised over your route list, catches the exact regression that produced the Eicher listing.

The 2.5 million passcodes

One detail in the disclosure deserves separate attention, because it is a design decision rather than a bug: the platform had retained 2.5 million one-time passcodes going back to 2021. A one-time passcode has a useful life measured in minutes. Storing them for years converts a transient secret into a permanent database of account-takeover material, and it is the reason the exposure escalated from a data leak to full account compromise.

Store a hash if you must store anything, set an expiry at the row level, and delete on use. Then apply the same question to everything else in your schema: session tokens, password reset links, signed upload URLs, webhook replay logs, support-tool impersonation grants. Anything that grants access and has no deletion path is accumulating. The same reasoning applied to build artefacts is why a security camera shipped a GitHub admin token in its login page, and applied to network surface is what our one-hour server lockdown covers.

Close it out

By the end of the evening you should have a file with four lists: routes reachable without credentials, routes that returned another tenant's data, path prefixes that enumerate, and admin functions callable by a member. Fix the first list before you sleep, because unauthenticated read access is the one an automated scanner finds without knowing your product exists. The rest can wait for the morning.

Then add one line to your deploy checklist: any new route that is intentionally public gets an explicit annotation and a comment saying why. The Eicher endpoints were not attacked by a sophisticated adversary. They were found by someone deleting the last segment of a URL, which means they were also findable by anyone who tried. If a single company controlling that much of your operational data is a familiar feeling, our platform dependency audit is the wider version of the same exercise.

Discussion

Sign in with Google or just a name. No email link, no password to remember.