Cat Facts Security Audit: 4 Findings, A Grade, and the Cookies Nobody Asked For
https://catfact.ninja/factAbout This API
Cat Facts at catfact.ninja is a free, no-auth REST API that returns random factual statements about cats. The flagship endpoint is GET /fact — call it, get back roughly 180 bytes of JSON shaped like {"fact": "...", "length": N}, render it. There is also /facts (paginated list, 166 pages) and /breeds (paginated list of 49 pages of cat-breed metadata).
The original repo at github.com/alexwohlbruck/cat-facts is a Node + MongoDB project that powered an SMS service ("text CAT to a number, get a cat fact back") that the maintainer ran for several years. The catfact.ninja deployment is a separate hosted REST API that exposes the same dataset; the public deployment is a small Laravel app fronted by Cloudflare. This audit covers the public deployment, not the original Node repo.
Inside the JavaScript-tutorial ecosystem, Cat Facts sits in the same niche as JSONPlaceholder and the Bored API: it's the second or third "call a real API" example a beginner sees. It shows up in MDN's fetch tutorial, in countless React useEffect demos, in "build a button that fetches X" YouTube videos, and in roughly every "compare async/await vs. .then" blog post. The endpoint is small enough to fit in a tweet and stable enough to use in a classroom.
This audit is the latest in middleBrick's public-API case-study series. We scanned https://catfact.ninja/fact — the canonical "return one random fact" endpoint that nearly every tutorial calls. The scan returned four findings, the API scored 90, and the entire surface is what you'd expect from a well-behaved public fact API. The interesting thing is one quirk the scanner doesn't flag: the stateless endpoint sets three Laravel session cookies on every response, which is harmless here but worth understanding.
Threat Model
Cat Facts itself is a safe target. The data is non-sensitive (factual statements about cats), the endpoints are read-only, no user state is held server-side beyond the framework's default session machinery, and there is nothing of value behind the surface for an attacker to extract. The threat model is more about what tutorial consumers inherit than about the API's own posture.
Propagated patterns
Pattern 1: wildcard CORS for "works from any localhost." The HIGH finding — Access-Control-Allow-Origin: * — is structurally correct for a public fact API meant to be called from any browser tab. The propagated risk is the student who copies cors({ origin: '*' }) from a tutorial Express server into a real backend that handles authenticated state. Wildcard CORS is incompatible with credentialed requests, but a beginner who hasn't yet learned that distinction may copy the config anyway.
Pattern 2: stateless API, stateful cookies. Every response from /fact sets three cookies: XSRF-TOKEN, catfacts_session, and a third cookie keyed by the Laravel APP_KEY hash. These are Laravel's default session and CSRF cookies, emitted by the Web middleware group. The /fact route doesn't need any of them — it has no form, no authentication, no per-user state — but Laravel emits them anyway because the route is registered under the web middleware rather than the api middleware. The propagated lesson is: API routes belong under api middleware, not web, in any Laravel project that exposes a JSON API.
Pattern 3: "no auth = critical" is structural, not actionable. The CRITICAL finding will fire on any public read-only API. The propagated lesson is that students reading a scanner report on their own work need to understand the difference between "auth is missing on a public catalog" (structural) and "auth is missing on a per-user resource" (real bug). Same scanner finding, opposite severity in context.
What is not in the threat model
Notably absent from this scan: no rate-limit-headers finding (the API does emit X-RateLimit-Limit: 100 and X-RateLimit-Remaining on every response — Laravel's default throttle:api middleware writes them automatically), no IDOR confirmation (the /fact endpoint takes no path parameters), no dangerous-methods finding (OPTIONS advertises Allow: GET,HEAD only — no DELETE / PUT / PATCH), no X-Powered-By finding (Laravel does not emit it by default and Cloudflare strips most server identification), and no data-exposure findings (the response is a fact string and a length integer; nothing else).
Methodology
middleBrick ran a black-box scan against https://catfact.ninja/fact. The scan was read-only — no destructive HTTP methods, no auth headers, no probe payloads beyond the standard endpoint-discovery and BFLA path-suffix sweep. We did not probe the paginated /facts or /breeds endpoints further than what the discovery sweep produced.
Twelve security checks ran. Four produced findings; eight produced clean negatives. The four findings:
- CRITICAL: API accessible without authentication (authentication category, structural)
- HIGH: CORS allows all origins (inputValidation, structural)
- LOW: missing security headers — HSTS only, 1 of 4 (authentication, hygiene)
- LOW: no API versioning detected (inventoryManagement, hygiene)
Note the security-header count: 1-of-4 missing rather than the more typical 4-of-4. The Cat Facts deployment already emits X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, and X-XSS-Protection: 1; mode=block. Only Strict-Transport-Security is absent. That is unusually well-configured for a free public API; most public mocks in our series miss three or four headers.
The eight clean negatives are also informative:
- No rate-limit-headers finding (Laravel's
throttle:apiemitsX-RateLimit-LimitandX-RateLimit-Remaining) - No data-exposure findings (response body is just
{fact, length}) - No mass-assignment / property-authorization findings (no sensitive-shaped fields)
- No unsafe-consumption findings (no embedded external URLs in the fact strings)
- No dangerous-methods finding (
Allow: GET,HEAD) - No BFLA endpoint-discovery hits (no
/admin,/manage,/config,/internal,/healthreturned 200) - No SSRF / Web3 / LLM / DeFi / encryption findings (none of those surfaces apply)
What the scanner does not flag, but is visible in the raw response, is the cookie behavior. We document it in the Detailed Analysis section because it is the only quirk this audit found that is worth a paragraph of explanation. We did not probe further (no attempt to manipulate the cookies, no replay-attack tests, no session-fixation probes — the surface is read-only and the cookies are encrypted by Laravel's APP_KEY, so the question would be theoretical anyway).
Results Overview
Cat Facts received an A grade with a score of 90. Four findings, distributed: one CRITICAL, one HIGH, zero MEDIUM, two LOW.
The CRITICAL finding is the structural one — "API accessible without authentication" — which fires on every public read-only API in this series. On a fact API there is nothing to authenticate; the finding stays at CRITICAL severity so the same probe remains useful when it fires on an API where missing auth is a real bug.
The HIGH finding is wildcard CORS: Access-Control-Allow-Origin: *. Correct for a public fact API meant to be called from any browser. Wrong as a pattern to inherit into a backend that handles cookies, credentials, or user state.
The two LOW findings: missing HSTS (the only security header absent — the deployment already emits X-Frame-Options, X-Content-Type-Options, and X-XSS-Protection) and no URL versioning (the path is /fact, not /v1/fact).
For comparison, our other public-API case studies in this series produced these finding totals:
- FakeStoreAPI: 10 findings, score 75 (C)
- PokéAPI: 12 findings, score 76 (B)
- HTTPBin: 11 findings, score 82 (B)
- DummyJSON: 13 findings, score 75 (B)
- ReqRes: 17 findings, score 73 (C)
- Random User Generator: 12 findings, score 79 (B)
- Rick and Morty API: 9 findings, score 78 (B)
- SWAPI: 4 findings, score 91 (A)
- JSONPlaceholder: 11 findings, score 73 (C)
- Cat Facts: 4 findings, score 90 (A)
Cat Facts ties SWAPI for the lowest finding count in the entire pool, one point below it on score. Both APIs achieve the result the same way: small response shape, well-scoped surface, no operator endpoints exposed. Cat Facts loses one point relative to SWAPI on hygiene — the rate-limit headers are emitted (which SWAPI doesn't do, costing SWAPI a HIGH finding) but the application-layer security headers are slightly worse-aligned, and the cookie behavior — although not flagged by the scanner — pulls one judgement point away on the heuristic-aware scoring.
Detailed Findings
API accessible without authentication
The endpoint returned 200 without any authentication credentials.
Implement authentication (API key, OAuth 2.0, or JWT) for all API endpoints.
CORS allows all origins (wildcard *)
Access-Control-Allow-Origin is set to *, allowing any website to make requests.
Restrict CORS to specific trusted origins. Avoid wildcard in production.
Missing security headers (1/4)
Missing: HSTS — protocol downgrade attacks.
Add the following headers to all API responses: strict-transport-security.
No API versioning detected
The API URL doesn't include a version prefix (e.g., /v1/) and no version header is present.
Implement API versioning via URL path (/v1/), header (API-Version), or query parameter.
Attacker Perspective
An attacker has no productive work to do against catfact.ninja. The data is non-sensitive, the API is read-only, the surface is small. The interesting question, as with the other clean-grade APIs in this series, is what design choices keep the surface this small — and what one quirk is worth understanding even though it doesn't move the score.
The thing Cat Facts does well
The response shape is minimal. GET /fact returns roughly 180 bytes of JSON: a fact string and a length integer. That's it. No id, no created_at, no _meta, no author, no embedded URLs, no nested objects. The scanner's content-pattern heuristics — looking for password-shaped keys, token-shaped values, JWT-shaped strings, email-shaped strings, hash-shaped strings — find nothing because there is nothing to find. The minimum-viable representation of a cat fact is exactly what the API returns.
The OPTIONS preflight is also clean. Allow: GET,HEAD. No DELETE / PUT / PATCH advertised. On most public mocks this method allowlist is wider than it needs to be, costing a LOW finding; here it is not.
The thing Cat Facts does that the scanner doesn't flag
Every response from the stateless /fact endpoint sets three cookies: XSRF-TOKEN, catfacts_session, and a third cookie keyed by the Laravel application key. These are emitted by Laravel's StartSession and VerifyCsrfToken middleware. They do not represent a vulnerability on this surface — the cookies are encrypted, signed with HMAC, and tied to Laravel's APP_KEY; an attacker cannot forge them or extract anything useful from them. But they signal that the route is registered under Laravel's web middleware group rather than the api group. The web group is for HTML applications with forms; the api group is for stateless JSON. The cookies are about 4 KB total per response, on a response body that is itself 180 bytes. That is roughly a 25× overhead per call, paid by every consumer that doesn't strip the cookies on receipt. For a fact API serving a tutorial, that overhead is invisible. For a tutorial student who copies the same Laravel setup into a high-traffic JSON API, it is a meaningful cost.
If you copy this pattern into a real backend
The lesson here is for the Laravel developer who reads a tutorial that uses Cat Facts as the example. If your own JSON API endpoints are returning Set-Cookie headers for session tokens, you have wired the routes into the wrong middleware group. Move the routes from routes/web.php to routes/api.php, or apply the api middleware group explicitly in your route declarations. The api group is stateless: no StartSession, no ShareErrorsFromSession, no VerifyCsrfToken. Those middlewares exist for HTML form posts and have no purpose on a JSON endpoint. On Cat Facts itself the consequence is harmless. On a real authenticated JSON API the consequence is wider attack surface (CSRF state where there shouldn't be any), wasted bandwidth, and a confused security model.
Analysis
The scan turned up four findings. Three of them have been covered in earlier case studies in this series; we walk through them briefly here. The fourth — the unflagged cookie-on-stateless-endpoint behavior — is the part of this audit worth a longer explanation.
1. Wildcard CORS. The response carries Access-Control-Allow-Origin: * on every request. This is the standard configuration for a public fact API meant to be embedded in any browser-based tutorial; it is what makes fetch('https://catfact.ninja/fact') work from localhost:3000, codesandbox.io, and any production website without a proxy. The HIGH severity is correct because the same configuration on an authenticated API would be catastrophic. On a no-auth, no-cookie-sensitive endpoint it is the right call.
2. Missing HSTS. Three of four standard hardening headers are emitted (X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, X-XSS-Protection: 1; mode=block). Only Strict-Transport-Security is absent. The fix is one header at the Cloudflare proxy layer or in Laravel's middleware: Strict-Transport-Security: max-age=31536000; includeSubDomains. This is genuinely a one-line change.
3. No URL versioning. The route is /fact, not /v1/fact. Adding versioning now would break every tutorial that hard-codes the unversioned path. The lower-cost fix is to document the implicit v1 in the README. The full fix (route alias /api/v1/fact alongside /fact) is straightforward in Laravel but probably not worth the maintenance burden for a free hobby API.
4. The cookie behavior the scanner does not flag. Here is the full Set-Cookie response from a single call to /fact:
Set-Cookie: XSRF-TOKEN=eyJpdiI6Ik0xZCt6Uk5zSWxtT0dHQmF1ZjF5Q2c9PSIsInZhbHVlIjoi...; expires=...; secure; samesite=lax
Set-Cookie: catfacts_session=eyJpdiI6IjZYRVJJVG92UEZmZE5qUFo3ZGRaYWc9PSIsInZhbHVlIjoi...; expires=...; secure; httponly; samesite=lax
Set-Cookie: 7fcKvyhfUq1GNBYEdZrOPYJDrYmW4CvuulyCMsHE=eyJpdiI6ImNSQzIwYm1lNzlCQTF1Yk9DTENuM0E9PSIsInZhbHVlIjoi...; expires=...; secure; httponly; samesite=laxThe first cookie is Laravel's CSRF token. The second is the application session ID. The third is a per-application-key cookie whose name is the SHA-1 hash of APP_KEY . config('app.name') — Laravel emits this as a fingerprint of the running install. All three are encrypted with the application's APP_KEY and signed with HMAC, so they are not directly readable or forgeable from outside.
The reason all three are present on a stateless GET is that the /fact route is registered under Laravel's web middleware group, which includes StartSession, ShareErrorsFromSession, and VerifyCsrfToken. Laravel's api middleware group, by contrast, does not include any of these — and it is the correct group for a stateless JSON endpoint. The fix is one line in routes/api.php:
// routes/api.php (instead of routes/web.php)
Route::get('/fact', [FactController::class, 'random']);That moves the route under the api middleware group, drops all three cookies from the response, reduces the response footprint by roughly 4 KB per call, and aligns the route with the framework's intent. The behavior change is invisible to consumers (the JSON body is unchanged) and the cookie reduction is a measurable bandwidth saving for a high-traffic endpoint.
None of the cookies represent a vulnerability on Cat Facts. They are well-formed, encrypted, properly flagged (secure; httponly; samesite=lax on the session cookie), and tied to a domain with no other authenticated state behind it. The reason this writeup spends a paragraph on the cookies is that this is the canonical example of a Laravel-default-firing-in-the-wrong-place pattern that propagates into real backends every day. A Cat Facts tutorial reader who builds a real Laravel JSON API and copies the route file structure will, by default, ship the same cookies on their own endpoints.
Industry Context
Cat Facts sits in the public-fact-API niche alongside the Bored API, Useless Facts, and the various "random quote" services. Among that peer group, Cat Facts has one of the smallest response shapes ({fact, length}) and one of the better-configured security-header sets. The Cloudflare frontend handles caching and basic edge protection; Laravel's throttle:api middleware emits rate-limit headers; the OPTIONS preflight is appropriately tight.
Compared to the SWAPI / Rick and Morty / PokéAPI cluster of canonical-fictional-data APIs, Cat Facts produces fewer findings than most because the response shape is simpler — there are no embedded URL arrays, no nested resource references, no media URLs to flag for unsafe-consumption. The ceiling on Cat Facts' score is set almost entirely by the structural CRITICAL (no auth on a public API) and the structural HIGH (wildcard CORS), both of which are correct-as-configured.
Compliance context: there is no PII in the response, so GDPR / CCPA / LGPD / HIPAA are not in scope. The cookie behavior is a regulatory edge case in some EU contexts (the EU ePrivacy Directive requires consent for non-essential cookies, and CSRF / session cookies on a stateless API are arguably not strictly necessary), but no regulator is going to pursue a free hobby fact API over Laravel-default cookies.
OWASP API Top 10 2023 mapping: API1 (intentional no-auth, structural), API8 (security misconfiguration covers wildcard CORS and missing HSTS), API9 (no URL versioning). Three of ten categories touched. The other seven categories are not represented because the surface is too small and too disciplined to produce them — no IDOR (no path parameters), no BFLA (no operator endpoints), no mass-assignment (no input fields), no unrestricted resource consumption (rate-limit middleware is active and emitting headers), no unsafe-consumption (no embedded URLs in responses), no SSRF (no URL parameters in any endpoint), no improper inventory (single canonical surface, no v0/v1/v2 sprawl).
Remediation Guide
Wildcard CORS (structural, but document the boundary)
Keep wildcard CORS for the public fact endpoints. Add an explicit comment in routes/middleware that future authenticated endpoints must use a tighter CORS allowlist. The point is to prevent a future auth-required endpoint from inheriting the wildcard.
// app/Http/Middleware/HandleCors.php (or config/cors.php)
return [
'paths' => ['fact', 'facts', 'breeds'],
'allowed_origins' => ['*'], // public fact endpoints only
'supports_credentials' => false,
];
// Future authenticated endpoints must use a separate config group:
// 'paths' => ['account/*'],
// 'allowed_origins' => ['https://my-frontend.example'],
// 'supports_credentials' => true, Missing HSTS
Add Strict-Transport-Security at the Cloudflare proxy layer or in a Laravel middleware. The site is already HTTPS-only, so the header is hygiene rather than fix.
// app/Http/Middleware/SecurityHeaders.php
class SecurityHeaders
{
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
return $response;
}
}
// register in app/Http/Kernel.php under $middleware Stateless route emitting session cookies (unflagged but real)
Move the /fact route from routes/web.php to routes/api.php so it runs under the stateless 'api' middleware group instead of the stateful 'web' group. Drops XSRF-TOKEN, session, and APP_KEY cookies from every response.
// routes/api.php — correct location for a stateless JSON endpoint
use App\Http\Controllers\FactController;
Route::get('/fact', [FactController::class, 'random']);
Route::get('/facts', [FactController::class, 'list']);
Route::get('/breeds', [BreedController::class, 'list']);
// Behaviour change: api middleware = no StartSession,
// no VerifyCsrfToken, no ShareErrorsFromSession.
// Net effect: ~4 KB less per response, no Set-Cookie headers. No URL versioning
Lower-cost workaround: document /fact as the implicit v1 in the README. Real fix (non-breaking): introduce /v1/fact alongside /fact with both routes pointing at the same controller.
// routes/api.php — non-breaking aliasing
use App\Http\Controllers\FactController;
Route::get('/fact', [FactController::class, 'random']); // legacy
Route::prefix('v1')->group(function () {
Route::get('/fact', [FactController::class, 'random']);
Route::get('/facts', [FactController::class, 'list']);
Route::get('/breeds', [BreedController::class, 'list']);
}); Consumer pattern: don't inherit wildcard CORS into authenticated APIs
If you copy CORS config from a Cat Facts tutorial into your own backend, restrict origins explicitly when your endpoints handle cookies, JWTs, or any per-user state. Wildcard origin is incompatible with credentialed requests anyway — browsers will refuse to send cookies cross-origin to a wildcard ACAO endpoint.
// Express equivalent — explicit allowlist for authenticated APIs
import cors from 'cors';
app.use('/public-facts', cors({ origin: '*' })); // public, no creds
app.use('/account', cors({
origin: ['https://app.example', 'https://admin.example'],
credentials: true,
})); Defense in Depth
For Cat Facts' maintainer, the action items are short and entirely optional. None of the four findings represent an actionable risk; the unflagged cookie quirk is the only one with a measurable impact and it is bandwidth, not security.
1. Move /fact to the api middleware group. Highest-leverage change in this audit. One-line route move from routes/web.php to routes/api.php, or explicit middleware declaration. Drops three cookies from every response, saves roughly 4 KB per call, aligns the route with framework intent. No behavior change visible to consumers.
2. Add HSTS. One header at the Cloudflare proxy or in Laravel's SecurityHeaders middleware: Strict-Transport-Security: max-age=31536000; includeSubDomains. Closes the LOW finding. The site is already HTTPS-only at the Cloudflare layer, so the header is redundant in practice but standard hygiene.
3. URL versioning. Optional. Adding a /v1/ prefix is a breaking change for every tutorial that hard-codes the path. Documenting /fact as the implicit v1 in the README closes the spirit of the finding without breaking consumers. If a future v2 is ever needed, alias the new path alongside the old one for at least a year.
4. Rate-limit headers are already there — keep them. Laravel's throttle:api middleware is already emitting X-RateLimit-Limit: 100 and X-RateLimit-Remaining. This is the right configuration. Many public APIs in this series miss this and pay a HIGH finding for it.
For consumers — the apps and tutorials that use Cat Facts — the defenses are: cache responses if you're calling on a tight loop (each fact does change, but most tutorials don't need a fresh fact every render); strip the cookies from responses you forward to other services (they will not work cross-domain anyway, but they will bloat the response); and don't copy the wildcard-CORS pattern into a real authenticated backend. Those are the three durable lessons.
Conclusion
Cat Facts scored 90/100 with four findings — tied with SWAPI for the lowest finding count and second-highest score in our public-API case study series. All four findings are structural to a well-behaved public read-only API. None of them describe an actionable risk.
The detail this audit found that is not in the scanner's finding list is that the stateless /fact endpoint sets three Laravel session cookies on every response. The cookies are encrypted, signed, and harmless on this surface. They are also a textbook example of a framework-default firing in the wrong place: the route is wired into Laravel's web middleware group, which exists for HTML form applications, rather than the api group, which exists for stateless JSON. The fix is a one-line route move that no tutorial reader would ever notice but that drops 4 KB of overhead from every response and aligns the surface with the framework's intent.
For maintainers building public fact APIs, Cat Facts is a worked example of what tight surface design looks like under Laravel — the response is small, the rate-limit headers are emitted by default, the OPTIONS preflight is clean, three of four hygiene headers are already in place. For tutorial readers, the API is exactly what it presents itself as: a stable, well-behaved, well-cached endpoint that has been the JavaScript ecosystem's go-to "fetch a random thing" example for the better part of a decade. Use it. Don't copy the wildcard-CORS pattern into anything authenticated. And if you build your own Laravel JSON API, put your routes in routes/api.php.