Security Audit Report

Chuck Norris Jokes Security Audit: 4 Findings on a Public No-Auth Fact Endpoint

https://api.chucknorris.io/jokes/random
90 A Good security posture
4 findings
1 Critical 1 High 2 Low
Share
01

About 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.

02

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.

03

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.

04

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.

05

Detailed Findings

Critical Issues 1
CRITICAL CWE-306

API accessible without authentication

The endpoint returned 200 without any authentication credentials.

Remediation

Implement authentication (API key, OAuth 2.0, or JWT) for all API endpoints.

authentication
High Severity 1
HIGH CWE-770

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).

Remediation

Implement rate limiting (token bucket, sliding window) and return X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers.

resourceConsumption
Low Severity 2
LOW CWE-693

Missing security headers (3/4)

Missing: HSTS — protocol downgrade attacks; X-Content-Type-Options — MIME sniffing; Cache-Control — sensitive response caching.

Remediation

Add the following headers to all API responses: strict-transport-security, x-content-type-options, cache-control.

authentication
LOW CWE-1059

No API versioning detected

The API URL doesn't include a version prefix (e.g., /v1/) and no version header is present.

Remediation

Implement API versioning via URL path (/v1/), header (API-Version), or query parameter.

inventoryManagement
06

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.

07

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.'

08

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.

09

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());
10

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.

11

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.

Frequently Asked Questions

Why is the score so high — isn't 'no authentication' a CRITICAL finding?
It is, and we left the severity at CRITICAL in the report. But the API is a public corpus of fictional jokes; world-readable is the design, not the bug. The score weighting takes context into account — a no-auth finding on a read-only public-fact endpoint with no PII and no per-user state doesn't tank the grade the way it would on, say, /api/users/me. The four findings combined produce a 90/100, which we round to an A.
Should I use api.chucknorris.io in a real product?
For a tutorial, side project, or internal team-Slack joke bot — yes, it's stable, free, and has been live for nearly a decade. For anything customer-facing where uptime matters, no — it's a free service with no published SLA. Cache the responses you do fetch (especially /jokes/categories, which essentially never changes) to be a polite consumer.
What are those nel and report-to headers in every response?
Heroku-injected Network Error Logging headers. They tell modern browsers to send error telemetry (failed requests, DNS issues, network errors) to nel.heroku.com so Heroku can monitor platform health. The signed URL in the report-to header carries a session ID and timestamp; it's not a vulnerability but it does leak the platform unmistakably and is the kind of header you'd want to know about if you were doing a careful audit.
If joke IDs aren't sequential, why didn't the IDOR check fire?
Exactly because of that. Joke IDs are 22-character URL-safe base64 strings like -TGIlhYySKG6PJn_rzs6qg, not /jokes/1, /jokes/2. The scanner's IDOR-via-enumeration heuristic walks the integer space; opaque non-enumerable IDs starve it of input. The IDs aren't a security feature on Chuck Norris (the data is public anyway) but they happen to look like what a properly-designed per-user resource ID should look like.
What should I unlearn from a 'fetch from Chuck Norris API' tutorial before building a real backend?
Three things. First, the no-credentials fetch shape — your real APIs will need an Authorization header on essentially every protected route. Second, the 'just call it in a loop' pattern — your real backends will rate-limit you and the Chuck Norris API just happens not to publish theirs. Third, the wildcard CORS assumption — Chuck Norris's CORS is permissive because the data is public; your authenticated APIs need an explicit allowlist of origins, not a *.