squirrelscan
GitHub

Unauthenticated API endpoints: when your API hands out data with no login

An API route that answers an anonymous request with user records has no server-side authorization. squirrel sends one GET with no cookies or token and grades what comes back by its field names.

Why an open API endpoint matters

A page can hide a button, redirect to a login screen or check a role in the browser, and still call an API route that answers anyone. If that route returns records to a request that carries no session cookie and no token, the authorization lives only in the frontend. Anyone who reads your JavaScript can find the route and call it directly.

This is common in apps built quickly, and in apps written with an AI assistant: the UI is gated, the API behind it is not.

What squirrel checks

This is a probing rule. It sends requests beyond the crawl, so it runs only when probing intensity is active or aggressive. On a passive run it sends nothing and records one skipped check. A cloud audit stays passive until you verify that you own the website.

Which endpoints are probed

Only endpoints the audit found in your own pages and scripts, such as a fetch("/api/users") or axios.get("/api/orders") call site, an API-looking string literal, or a convention path for your detected stack. Finding them sends no request. This rule adds no guessed paths of its own.

Every endpoint must have the same origin as the audited URL (scheme, host and port). An endpoint on another host, such as a vendor API, is never probed. Beyond that:

  • A path whose only call sites send POST, PUT, PATCH or DELETE is never probed, not even with a GET. A path with a GET call site is probed even when other call sites POST to it.
  • A path that names a state-changing or session action is never probed: words such as logout, delete, update, reset, login, auth, callback, verify, confirm, token, webhook, cron, send or sync.
  • GraphQL endpoints are left to security/graphql-introspection, and Next.js _next/data payloads, health, metrics and API-spec paths (/health, /metrics, /actuator, /openapi.json, /swagger.json) are left out too.
  • Below aggressive, a path that robots.txt disallows is skipped.
  • At most ten endpoints are probed, call sites before string literals before convention paths.

What is sent

One request per endpoint:

GET /api/users
Accept: application/json, */*;q=0.1

The query string and fragment of the discovered URL are dropped. The request carries no cookies, no Authorization header and no API key, and does not follow redirects. At most 256 KB of the answer is read, counted after decompression, so a compressed answer cannot inflate past the limit.

How an answer is graded

An answer is flagged only when it is a 2xx JSON body that carries records:

  • an array of objects ([{...}, {...}]);
  • an array of objects under a wrapper key such as data, results, items, records or rows, or under any top-level key ({"users": [...], "total": 2});
  • or a single object with an id field (id, _id, uuid, userId) and at least two other fields.

The field names of the records, including one level of nested objects such as author.email or JSON:API attributes, decide the result:

FieldsExamplesResult
Credentialspassword, password_hash, salt, api_key, secret, access_token, session_idFail (high)
Personal dataemail, phone, address, postcode, date_of_birth, ssn, ip_address, card_number, ibanFail (high)
Internal fieldsrole, is_admin, permissions, stripe_customer_id, tenant_id, last_login_at, internal_notesFail (high)
User identity, or nothing tellingusername, user_id, first_name, avatar_urls, or fields that say nothing either wayWarn (medium)
Only published contenttitle, slug, excerpt, body, price, sku, image, tagsInfo (low)

A public product catalog or blog feed is meant to be open, so content fields alone stay informational. One sensitive field anywhere in the records is enough to fail.

What a finding shows

The finding names the endpoint, the number of records and the field names, sensitive ones first. It never carries a value from the response. The item meta has a redacted shape of the first record, field names and value types only:

GET https://example.com/api/users: 2 records (password_hash, email, role)
sample: {id: number, email: string, password_hash: string, role: string, name: string}

A field name that looks like data itself, such as an email address, a long number or a UUID used as a map key, is left out and only counted. When the answer is longer than 256 KB and opens as a list of records, field names are read from the first 256 KB, the record count is unknown, and the item meta has truncated: true and confidence: "lower".

Results

  • unauthenticated-api-sensitive (fail): records with credential, personal or internal fields. Message: 1 API endpoint(s) return records with credential, personal or internal fields to a request with no cookies or Authorization header, for example https://example.com/api/users (password_hash, email, role).
  • unauthenticated-api-data (warn): records with user-identity fields, or with fields that do not say whether the data is public.
  • unauthenticated-api-public (info): records that read as published content.
  • unauthenticated-api (pass): no probed endpoint returned records, or no endpoint could be probed.
  • unauthenticated-api-unchecked (skipped): probing is passive (probing-passive), the shared probe budget ran out before or partway through the endpoints (probe-budget), or every probe failed with a network error or timeout (probe-errors). A run cut short is never a pass, because the endpoints it did not reach were not checked.

What is not flagged

  • A 401, 403, any other non-2xx status, or a redirect (to a login page, for example).
  • An HTML page, a catch-all route, or a body that is not JSON.
  • A JSON body with no records: a health answer such as {"status":"ok"}, an error such as {"error":"unauthorized","message":"Sign in"}, an empty list or a list of plain values.

Limits

An anonymous probe shows that an endpoint answers without credentials. It cannot show that one signed-in user can read another user’s records (an IDOR), because that needs two accounts. Test that separately.

The grade comes from field names, not values, so a field with an unusual name is not recognised. An endpoint that needs a query parameter to return data, or a path parameter the audit could not see, may answer with an error and is not flagged. An endpoint the crawl never referenced is not found.

How to fix it

  • Check authorization on the server, in every API route that returns user or business data, and answer 401 or 403 when the request carries no valid session or token. Middleware that guards the whole /api prefix is the simplest way to make the default closed.
  • Do not rely on the frontend: a hidden button or a client-side route guard does not protect the API behind it.
  • Return only the fields the caller needs. Never send password hashes, tokens, emails or roles in a list response. Select columns explicitly instead of returning whole rows.
  • If the endpoint is meant to be public, make sure it returns only public fields, then the finding drops to informational or clears.
  • If you use Supabase or Firebase, turn on row level security or database rules: a client key alone must not be able to read every row.
Rule IDsecurity/unauthenticated-api
CategorySecurity
ScopeSite-wide
Severityerror
Weight7/10

Enable / disable

Disable this rule

[rules]
disable = ["security/unauthenticated-api"]

Disable all Security rules

[rules]
disable = ["security/*"]

Enable only this rule

[rules]
enable = ["security/unauthenticated-api"]

enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.

Check names by outcome

Each check name this rule can emit, by what it means to a tool comparing two audits. A problem check is a finding. A clean check says the rule ran over what it covers and found nothing; it clears the problem checks listed beside it, so a finding is fixed when the rule ran over the same scope and a clean check took its place. A not checked check says the rule did not evaluate, and never clears a finding.

Check nameOutcomeClears
unauthenticated-api-sensitiveProblem
unauthenticated-api-dataProblem
unauthenticated-api-publicProblem
unauthenticated-apiCleanunauthenticated-api-sensitive, unauthenticated-api-data, unauthenticated-api-public
unauthenticated-api-uncheckedNot checked

Type to search…

↑↓ navigate↵ openesc close