All engineering notes
SecuritySeptember 24, 20265 min read

Cross-tenant reads return 404, not 403

Forbidden tells an attacker that a resource exists. Cacheify answers cross-tenant requests with the same 404 it uses for an unknown database.

Error codes are an information channel. A carefully chosen code can confirm the existence of a tenant, a database or a key without returning any of its data.

The rule

Every lookup is scoped to the authenticated owner. A request for a database that belongs to another account returns 404, identical to a request for a database that never existed. The platform does not distinguish "not yours" from "not found" in the response.

The same principle applies to keys. A key name that exists in another tenant namespace is not observable through this account. Within your own database, a missing or expired key is also 404, which keeps the cache-miss contract simple.

Both cases, same answer
GET /api/data/rdb_someone_else/keys/session:1
Authorization: Bearer rk_your_key

404 { "success": false, "error": { "code": "ERR_NOT_FOUND" } }

Where 403 is still correct

403 is reserved for cases where the caller already has a legitimate relationship with the resource: a read-only key attempting a write, or a key used outside the database it was minted for. Those responses tell an authenticated caller that the request was understood and refused, without revealing anything they did not already know.

Admin endpoints are a separate surface. A non-admin session receives 403 because the session is valid and the role check failed. That is not tenant enumeration; the route itself is public knowledge.

Why it matters in a cache

Cache keys often contain predictable identifiers: user IDs, session prefixes, feature names. If a cross-tenant probe returned 403 for existing databases and 404 for missing ones, an attacker could map another account's namespace without reading a single value.

Non-disclosure is cheap to implement at the service boundary and expensive to retrofit after clients depend on richer error semantics. Doing it from the first request keeps the contract honest.

Try the contract yourself.

Five databases are free. Create one, mint a scoped key and send a command.