Chuck Norris Jokes Security Audit: 4 Findings on a Public No-Auth Fact Endpoint
https://api.chucknorris.io/jokes/randomAbout This API
api.chucknorris.io is a free, no-auth REST API that serves random Chuck Norris facts. It exposes three primary endpoints: /jokes/random (the canonical tutorial target), /jokes/categories (returns 16 category names like animal, dev, movie, science), and /jokes/search?query=... (full-text search, returns up to 156 results per query). Each joke record is small and consistent: id, value (the joke text), categories, icon_url, url, created_at, updated_at.
The project is maintained by the chucknorris-io GitHub organization. The repository chucknorris-io/chuck-api is the backend; the data is curated and effectively static — most records show created_at and updated_at timestamps from 2020-01-05, suggesting the corpus was bulk-imported once and rarely modified since. The API has been in continuous operation for the better part of a decade and is one of the most cited APIs in JavaScript bootcamps, freeCodeCamp lessons, MDN fetch() walkthroughs, and YouTube 'first React app' tutorials.
Infrastructure-wise, the service runs on Heroku (the response carries via: 1.1 heroku-router and a nel / report-to header pointing at nel.heroku.com) fronted by Cloudflare (the server: cloudflare and cf-ray headers). That stack matters for two reasons: it explains why the rate-limit behavior at the edge differs from what the origin app would emit on its own, and it's the source of the Network Error Logging telemetry headers in every response — a piece of operator surface that's interesting to call out even though it isn't a finding.
Threat Model
The threat model here is essentially empty. The data is fictional, the endpoints are read-only, no user state exists. An attacker compromising api.chucknorris.io gets nothing of value beyond defacing a joke corpus that lives in a database somewhere on Heroku. The only operational risk is denial-of-service against the Heroku origin, and Cloudflare in front of it absorbs that.
Propagated patterns
The interesting threat surface, as with every public-fact API in this niche, is what the service teaches the developers consuming it. Two patterns matter.
Pattern 1: no-auth read-only fact endpoint. The CRITICAL finding observes that /jokes/random returns 200 with no credentials. On a public jokes API this is the API working exactly as designed. The propagated risk is that students who copy this pattern into /api/users/me, /api/orders, or any per-user resource without adding authentication ship a broken system. The Chuck Norris API doesn't have any concept of 'a user'; the apps people build after the tutorial almost always do.
Pattern 2: rate-limit-headerless responses. The HIGH finding is that no X-RateLimit-* or Retry-After headers appear on the response. Any rate limiting that exists is enforced silently at Cloudflare's edge or at the Heroku app layer. Consumers can't see their budget pre-emptively and have to discover the limit by hitting it. Students who learn 'just call fetch() in a loop' against this endpoint and then point the same loop at a real backend with a strict per-IP quota learn the hard way that the limit existed.
What's not in the threat model
Notably absent from the findings list: no IDOR signal (joke IDs are non-sequential 22-character base64 strings like -TGIlhYySKG6PJn_rzs6qg, not /jokes/1, /jokes/2), no data-exposure heuristic hits (the response carries no email-shaped, password-shaped, or token-shaped fields), no mass-assignment surface (the response is flat), no operator endpoint exposure (the BFLA suffix sweep against /admin, /manage, /config, /health, etc., found nothing), no LLM-security signal (the response is canned text from a static corpus, not generative output), and no Web3 signal. The CORS configuration is wildcard (access-control-allow-origin: *) on preflight responses, but only after the API observes an Origin header on the request — and the scanner did not raise it as a finding for this scan, possibly because the simple GET against /jokes/random doesn't trigger a preflight.
Methodology
middleBrick ran a black-box scan against https://api.chucknorris.io/jokes/random. Read-only — no destructive HTTP methods, no auth headers, no fuzz payloads beyond the standard endpoint-suffix sweep used for BFLA detection.
Fourteen security categories were exercised. Four produced findings. The four findings:
- CRITICAL: API accessible without authentication (authentication category, structural)
- HIGH: missing rate-limit headers (resourceConsumption, structural)
- LOW: missing security headers — HSTS, X-Content-Type-Options, Cache-Control (authentication category, hygiene)
- LOW: no API versioning detected (inventoryManagement, hygiene)
The rest of the categories — BOLA, BFLA, property-level authorization, input validation, data exposure, encryption, unsafe consumption, SSRF, LLM security, Web3 security, DeFi security — produced clean negatives. We did not separately scan the /jokes/categories or /jokes/search endpoints; if the search endpoint accepted enough query parameters to be ReDoS-vulnerable on the Heroku side or echoed query strings without escaping, this scan would not have surfaced it. We also did not probe further on the OPTIONS preflight, which advertises an over-broad Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH response header even though only GET is actually accepted — a server-default leak that's interesting but didn't rise to a finding because the methods are not actually allowed.
Results Overview
api.chucknorris.io received an A grade with a score of 90. Four findings: one CRITICAL, one HIGH, zero MEDIUM, two LOW.
The CRITICAL is 'API accessible without authentication.' True and structural. The Chuck Norris jokes API is meant to be world-readable — it would be a defect if it weren't. We leave the severity at CRITICAL because the signal needs to keep firing on real APIs where no-auth-on-a-protected-endpoint is the actual bug; a maintainer reading this can mentally downgrade it for their context.
The HIGH is 'missing rate-limit headers.' Response has no X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, or Retry-After. Any throttling that exists at Cloudflare's edge or at the Heroku app is invisible to the consumer.
The two LOWs are 'missing security headers' (HSTS, X-Content-Type-Options, and Cache-Control absent — the API does set X-Frame-Options: DENY, which is why this is 3-of-4 missing rather than 4-of-4) and 'no API versioning detected' (path is /jokes/random with no /v1/ prefix and no version header).
For comparison against other public no-auth APIs in our case-study series:
- FakeStoreAPI: 10 findings, score 75 (C)
- JSONPlaceholder: 11 findings, score 73 (C)
- HTTPBin: 11 findings, score 82 (B)
- DummyJSON: 13 findings, score 75 (B)
- Random User Generator: 12 findings, score 79 (B)
- Rick and Morty API: 9 findings, score 78 (B)
- SWAPI: 4 findings, score 91 (A)
- Chuck Norris Jokes: 4 findings, score 90 (A)
Chuck Norris is tied with SWAPI for the cleanest result we've seen on a public no-auth fact API. The follow-up question is what the service did right.
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.
Missing rate limiting headers
Response contains no X-RateLimit-* or Retry-After headers. Without rate limiting, the API is vulnerable to resource exhaustion attacks (DoS, brute force, abuse).
Implement rate limiting (token bucket, sliding window) and return X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers.
Missing security headers (3/4)
Missing: HSTS — protocol downgrade attacks; X-Content-Type-Options — MIME sniffing; Cache-Control — sensitive response caching.
Add the following headers to all API responses: strict-transport-security, x-content-type-options, cache-control.
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 nothing to do here. The data is fictional, the API is read-only, the surface is small and well-scoped. The interesting question — same as it was on SWAPI — is what about the API's design choices kept the surface small.
The thing the API doesn't include in its responses
The single biggest contributor to the clean result is response-shape discipline. A /jokes/random response is 340 bytes of JSON: id, value, categories, icon_url, url, created_at, updated_at. That's it. No nested objects. No internal database IDs (the id is a 22-char URL-safe base64 string, opaque and non-enumerable). No metadata fields with leading underscores. No author or curator information. No response-envelope wrapper with status, code, data nesting. Just the joke and the metadata a consumer needs to display it.
The non-sequential opaque IDs are the second contributor. /jokes/-TGIlhYySKG6PJn_rzs6qg is not the kind of URL you can iterate. Compare to APIs that expose /jokes/1 through /jokes/N: those produce an automatic IDOR-style finding from any scanner that walks the integer space. Chuck Norris's IDs aren't a security feature (the data is public anyway) but they happen to be exactly what an ID column in a per-user table should look like, and they keep the scanner's enumeration heuristics quiet.
The thing worth flagging that didn't quite become a finding
Every response carries Heroku's NEL (Network Error Logging) and reporting-endpoints headers, including a signed reporting URL that contains a session ID and timestamp:
nel: {"report_to":"heroku-nel","response_headers":["Via"],"max_age":3600,...}
report-to: {"group":"heroku-nel","endpoints":[{"url":"https://nel.heroku.com/reports?s=...&sid=...&ts=..."}]}
reporting-endpoints: heroku-nel="https://nel.heroku.com/reports?s=...&sid=...&ts=..."This isn't a vulnerability — Heroku adds these to enable browser-side network-error telemetry, and the signed URL prevents tampering. But it does leak the platform (Heroku) unmistakably and ships a per-session identifier that a determined adversary could use to fingerprint clients across requests if they had control of the reporting endpoint, which they don't. We mention it because the scanner did not flag it and a careful reader of headers should know what those three lines are doing.
Analysis
Walking through how the Chuck Norris API's design choices each contribute to the absence of a typical finding.
1. No CORS-wildcard finding on the simple GET. A bare GET /jokes/random with no Origin header gets a response without an access-control-allow-origin header at all. The wildcard is set on preflight responses (OPTIONS with Origin + Access-Control-Request-Method headers), and on cross-origin GETs it appears as access-control-allow-origin: *, but the scanner's GET-based probe doesn't trigger it. This is one of those cases where a header that is a wildcard simply doesn't appear in the scanner's view because the request didn't ask for it. The wildcard is correct for a public read-only fact API; it's worth flagging that a scanner relying solely on bare GETs may miss it.
2. No data-exposure findings. The response body has no field names matching password, token, api_key, secret, email, ssn, credit_card, or any sensitive-name pattern. There are no email-shaped strings, JWT-shaped strings, or hash-shaped strings in the body. The joke text itself is just text; the surrounding metadata is dates, URLs, and a category array.
3. No mass-assignment finding. The response shape is flat. There are no role, is_admin, permissions, privileged, or other authorization-shaped keys for the heuristic to catch.
4. No unsafe-consumption finding on embedded URLs. The response includes two URL fields (icon_url and url), both pointing at api.chucknorris.io itself. They are not arbitrary external URLs; they're internal self-references. The scanner's unsafe-consumption heuristic looks for embedded references to third-party hosts that a client might fetch without validation, and this response has none.
5. No BFLA endpoint-discovery hits. The probe sweep against /admin, /manage, /config, /health, /internal, /.env, etc., returned 404 across the board. The Chuck Norris service has no operator surface exposed under the same hostname; whatever ops endpoints exist for the maintainer presumably live behind authentication on a different host or on Heroku's own dashboard.
6. The OPTIONS preflight quirk. An OPTIONS request returns access-control-allow-methods: GET (correctly limiting cross-origin requests to GET) but also allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH in the same response. The Allow header is the standard HTTP one (RFC 7231) and lists what the server-default would handle if the route existed; the Access-Control-Allow-Methods is what the CORS layer actually permits cross-origin. They disagree, and that disagreement is the kind of thing that can confuse an automated discovery tool — but it's not a finding because PUT/POST/DELETE/PATCH against /jokes/random all return 405 in practice. The CORS layer is doing its job; the framework default Allow header is just a leak of 'this is what an HTTP server can do' rather than 'this is what this route accepts.'
Industry Context
api.chucknorris.io sits in the same niche as SWAPI, the icanhazdadjoke API, the Bored API, the Cat Facts API, and PokeAPI — public read-only fact endpoints whose primary audience is JavaScript learners. Among that peer group, Chuck Norris and SWAPI have the smallest response payloads and the cleanest schemas; PokeAPI returns large nested objects per Pokémon and predictably produces more findings; FakeStoreAPI returns plausible e-commerce shapes that come with their own propagation hazards.
For compliance: there is no real PII in the responses, so GDPR / CCPA / LGPD / PIPEDA are not in scope. The joke corpus contains some entries tagged explicit (queryable via ?category=explicit on the random endpoint), which is a content-rating concern more than a security one — apps embedding the API in contexts with audience restrictions should opt out of the explicit category at the query layer.
OWASP API Top 10 2023 mapping: API1 (broken object-level authorization) is technically present in the no-auth finding but structurally — the catalog is meant to be world-readable, like a Wikipedia article. API4 (unrestricted resource consumption) covers the missing rate-limit headers. API8 (security misconfiguration) covers the missing HSTS / X-Content-Type-Options / Cache-Control. API9 (improper inventory management) covers the no-versioning finding. The remaining six categories are not represented because the surface is too small and too read-only to produce them.
Remediation Guide
Missing rate-limit headers
Add express-rate-limit with the IETF draft-7 standard-headers option. Throttling that exists at Cloudflare's edge can stay as the outer layer; the application-level limit and its headers give consumers visibility into their budget.
import rateLimit from 'express-rate-limit';
app.use('/jokes', rateLimit({
windowMs: 60_000,
max: 600, // 10 req/sec/IP — generous for a tutorial API
standardHeaders: 'draft-7', // emits RateLimit-Limit / Remaining / Reset
legacyHeaders: false,
message: { error: 'rate_limit_exceeded' }
})); Missing HSTS + X-Content-Type-Options
Add at Cloudflare's edge via Transform Rules → Modify Response Header. No application deploy required.
# Cloudflare Transform Rule: response header set
# Match: hostname equals api.chucknorris.io
# Action: Set static — header name / value pairs:
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff Missing Cache-Control on /jokes/random
/jokes/random should not be cached anywhere — the whole point is a different joke each call. /jokes/categories can be cached aggressively because the list of 16 categories changes essentially never.
// Express route
app.get('/jokes/random', (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(getRandomJoke(req.query.category));
});
app.get('/jokes/categories', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
res.json(CATEGORIES); // 16-element static array
}); No URL versioning
Lower-cost workaround: document /jokes/ as the implicit v1 in the README. Real fix (breaking change): introduce /v1/jokes/ alongside /jokes/ with a deprecation timeline for the unversioned form. Given the API's tutorial-anchor status, the workaround is the right call.
// Express: alias /v1/ to / without breaking existing consumers
import jokesRouter from './routes/jokes.js';
app.use('/jokes', jokesRouter);
app.use('/v1/jokes', jokesRouter); // alias — same handler, future-safe URL Over-broad Allow header on OPTIONS responses
Explicitly set Allow on the OPTIONS handler to match reality. Closes the discovery-tool confusion where Allow lists DELETE/PUT/PATCH but the routes return 405 in practice.
app.options('/jokes/random', (req, res) => {
res.set('Allow', 'GET, HEAD, OPTIONS');
res.set('Access-Control-Allow-Methods', 'GET');
res.status(204).end();
}); Consumer pattern: don't carry no-auth-on-GET into authenticated resources
If you're using Chuck Norris in a tutorial as the 'first fetch' lesson, the next lesson should explicitly introduce an Authorization header — even a fake one — so students don't internalize 'fetch without credentials' as the default API call shape.
// Bad pattern propagation: works against Chuck Norris, breaks against everything else
const jokes = await fetch('https://api.chucknorris.io/jokes/random').then(r => r.json());
// Healthier teaching pattern: introduce auth headers immediately after
const me = await fetch('/api/users/me', {
headers: { 'Authorization': `Bearer ${token}` },
credentials: 'include'
}).then(r => r.json()); Defense in Depth
For the chucknorris-io maintainers, the action items are short and optional. None of the four findings represent an actionable risk to the API itself.
1. Add HSTS at Cloudflare. Cloudflare has a one-click HSTS toggle in the SSL/TLS section of the dashboard. Strict-Transport-Security: max-age=31536000; includeSubDomains closes one of the three missing security headers without requiring a deploy.
2. Add X-Content-Type-Options: nosniff. Same dashboard, Transform Rules → response header modification. One header, zero behavior change.
3. Add Cache-Control. The /jokes/random endpoint should probably emit Cache-Control: no-store (the response is supposed to be different on every request) while /jokes/categories could safely emit a long-lived Cache-Control: public, max-age=86400. Setting these explicitly closes the LOW finding and avoids edge nodes accidentally caching the random endpoint.
4. Emit rate-limit headers. Express has express-rate-limit with the standardHeaders: 'draft-7' option that writes RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset. Even a generous 600/minute/IP policy with headers is better than silent throttling at the edge — consumers can back off pre-emptively instead of discovering the limit by getting 429s. The HIGH finding goes away.
5. Versioning is optional. Adding a /v1/ prefix is a breaking change for every tutorial that hard-codes /jokes/random. The lower-cost workaround is to document the implicit v1 in the README and reserve /v2/ for any future incompatible change. Closes the spirit of the finding without breaking the consumer ecosystem.
6. Optional: tighten the Allow header. The framework-default Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH on the OPTIONS response is misleading. Setting Allow: GET, HEAD, OPTIONS explicitly on the route handler matches reality and avoids confusing automated discovery tools.
For consumers — the bootcamp tutorials and beginner JS apps that use this API — the defenses are: cache responses where appropriate (/jokes/categories is essentially static), respect Cloudflare's per-IP throttle even though it isn't published, and if you're copying this API's pattern into your own backend, do not copy the no-auth-on-a-real-resource part.
Conclusion
api.chucknorris.io scored 90/100 with four findings. Tied with SWAPI for the cleanest result we've seen on a public no-auth fact API. The four findings are all structural to a public read-only catalog and none of them describe an actionable risk to the service itself.
The interesting analysis is what the API doesn't expose: 340-byte response shapes, no internal metadata, opaque non-sequential joke IDs, no operator endpoints under the same hostname, no LLM or Web3 signals, no embedded third-party URLs in the response body. Several typical scanner findings simply don't appear because the response shape gives them nothing to fire on. The Heroku-injected NEL/reporting-endpoints headers are the most interesting piece of operator surface visible to a careful reader, and they're not a vulnerability — just a platform tell.
For maintainers building public-fact APIs, Chuck Norris is a worked example of disciplined response-shape design. The two optional improvements (rate-limit headers, the three missing security headers via Cloudflare's dashboard) would push the score above 95 without a single line of application code change.
If you copy this pattern into a real backend — an authenticated user resource, an order endpoint, a profile API — do not copy the no-auth-on-GET part. The Chuck Norris API is a public corpus where every record is meant to be world-readable. Your /api/users/me is not. The pattern that's safe here is dangerous one schema change later.