Bored API Security Audit: 15 Findings, 6 of Them an Express SPA Catch-All Firing 200
https://www.boredapi.com/api/activityAbout This API
Bored API is the canonical 'give me a random activity' service. GET /api/activity returns a single JSON object with seven fields: activity (a one-line suggestion such as 'Plant a tree' or 'Learn a new programming language'), type (a category like recreational, education, social, charity, busywork, music, diy, cooking, relaxation), participants (typically 1), price (a 0.0-1.0 cost score), link (sometimes empty, sometimes a related URL), key (a numeric identifier for the activity), and accessibility (a 0.0-1.0 ease-of-doing score). The total payload is around 120-180 bytes per response.
The service became a tutorial standard for one reason: it is the smallest possible 'real' API. A learner who wants to demonstrate fetch, useEffect, async/await, error handling, or React-Query needs a target that returns something interesting and changes between calls. Bored API is exactly that. It is the 'random X' API in dozens of YouTube intro tutorials, the example endpoint in countless React, Vue, and Svelte 'data fetching' walkthroughs, and the suggested project target in 'I'm bored, build something' Reddit threads. Apps shipped against it include Chrome extensions, Discord bots, kid-friendly browser activities, and weekend side projects.
The original implementation at www.boredapi.com (the GitHub repo at drewthoennes/Bored-API) shut down in 2024. The domain no longer resolves. The JavaScript ecosystem migrated to bored.api.lewagon.com, a near-identical Express mirror maintained by Le Wagon (the coding bootcamp); it serves the same schema and the same activity database. This audit was run against the live mirror because that is what 'use Bored API' means in practice today. The repository linked from this case study is the original drewthoennes/Bored-API source, since it remains the canonical reference implementation that taught the community what 'a Bored API' looks like.
This audit is the latest in middleBrick's public-API case-study series. We scanned https://bored.api.lewagon.com/api/activity as a black-box GET. Fifteen findings came back. Most of the interesting analysis is about which of them describe the API and which describe the scanner colliding with an Express SPA fallback.
Threat Model
Bored API holds no data of any value to an attacker. The activities are public, the response is short, the schema is fixed, no user ever logs in, no state is held server-side beyond the activity database. There is no attack against Bored API itself that is worth running.
Propagated patterns
The interesting threat surface — as with every entry in this case-study series — is what Bored API teaches the developers consuming it. Two patterns matter here.
Pattern 1: the Express SPA catch-all that returns 200 on everything. The most striking observation from this scan is that /admin, /manage, /system/health, /debug, /wjelkjsdfjkldsf, and any other unmatched path all return HTTP 200 with the same 638-byte SPA shell. This is Express's classic app.get('*', (req, res) => res.sendFile('index.html')) pattern, and it is everywhere in tutorial-derived backends. Students who copy this pattern into a real backend ship an API where every wrong path looks like success — which breaks 404 handling on the consumer side, defeats endpoint-existence checks, and (more seriously) makes the wrong-path / right-path distinction invisible to log analysis and to security scanners. We saw this exact misclassification in our own scanner: the BFLA probe correctly observed 200 on five 'privileged-named' paths, but the underlying response body was identical and was the docs page. On a real API, that pattern hides real endpoints behind a 200-on-everything fog.
Pattern 2: write methods advertised but unimplemented. OPTIONS on /api/activity returns Allow: GET, HEAD, POST, PUT, DELETE. PUT and DELETE actually return HTTP 200 with the body {"error":"Endpoint not found"} — they are no-ops, but they look successful at the HTTP-status level. This is the Express default-behavior pattern when the application doesn't explicitly implement a method handler: the request flows to a catch-all that sends 200 with an error JSON instead of returning 405 Method Not Allowed. On a public mock this is harmless. On any real API this pattern means automated scanners and CI checks see DELETE returning 200 and have to reason about the body to decide whether the resource was actually deleted.
What's not in the threat model
No PII, no auth flow, no user data, no payment data, no LLM inference. The data exposure heuristics, mass-assignment heuristics, SSRF heuristics, and LLM heuristics all came back clean. The CORS wildcard finding is structural to a service designed to be called from any tutorial origin.
Methodology
middleBrick ran a black-box scan against https://bored.api.lewagon.com/api/activity. Fourteen security check categories executed; fifteen findings were produced across seven categories. The scan was read-only — the engine never sent destructive payloads, did not modify resources, and observed without authenticating.
The BFLA endpoint-discovery probe (plus the input-validation /debug probe) is what produced most of the noisy HIGH findings. It walks a fixed list of administrative-named paths (/admin, /manage, /api/admin, /api/config, /system/health, /debug, /health, /internal, etc.) and flags any that return 200. We did not probe further at scan time — middleBrick is read-only and does not auto-fingerprint response bodies to detect SPA-shell collisions. Manual follow-up after the scan confirmed: /admin, /manage, /system/health, and /debug all return the identical 638-byte React SPA documentation page that the API serves at the root, while /api/admin and /api/config return a 30-byte JSON 'Endpoint not found' at HTTP 200 instead of the proper 404. Six findings, one root cause.
The CORS check observed Access-Control-Allow-Origin: * on the response and fired the wildcard-CORS HIGH finding. The framework-disclosure check observed X-Powered-By: Express in the response headers. The HSTS check observed Strict-Transport-Security: max-age=15724800; includeSubdomains — that's 182 days, under the recommended 1-year minimum, so the encryption category fired a LOW. The rate-limit check observed no X-RateLimit-* headers and no Retry-After on the response.
Active method probing established that OPTIONS on /api/activity advertises GET, HEAD, POST, PUT, DELETE. Manual follow-up confirmed PUT and DELETE return 200 with a JSON error body — they are unimplemented but Express's catch-all responds 200 instead of 405 Method Not Allowed.
What did not fire is also worth noting: data-exposure heuristics (no password / token / email / API-key patterns in the response body), mass-assignment (no role, is_admin, permissions keys), SSRF (the response contains no embedded URLs that resolve to internal hosts), LLM-security (the API is plain REST, not LLM-backed), Web3 (not a JSON-RPC endpoint).
Results Overview
Score: 75/100, grade B (boundary). Finding distribution: 1 CRITICAL, 8 HIGH, 0 MEDIUM, 6 LOW.
The single CRITICAL is 'API accessible without authentication.' This is structural — Bored API is a public no-auth service by design, and a CRITICAL finding is what every public mock in this series produces on this check. The signal is correct. The severity is intentional on this kind of API.
The eight HIGH findings break into two groups:
- Six SPA-fallback findings: five 'privileged endpoint accessible' findings on /admin, /api/admin, /api/config, /manage, /system/health, plus one 'debug endpoint accessible' finding on /debug. All six are scanner artifacts. /admin, /manage, /system/health, and /debug each return the same 638-byte React SPA documentation shell. /api/admin and /api/config return a 30-byte JSON 'Endpoint not found' — same defect class (200 instead of 404), different body. There are not six admin surfaces; there is one Express catch-all serving HTTP 200 on every unmatched path.
- Two real HIGHs: wildcard CORS (intentional for a service called from arbitrary tutorial origins; structural rather than actionable) and missing rate-limit headers (genuinely missing — no
X-RateLimit-*orRetry-Afteron any response).
The six LOW findings are: no auth on any HTTP method (same root cause as the CRITICAL — restated at the method level), three of four security headers missing (X-Content-Type-Options, X-Frame-Options, Cache-Control), DELETE/PUT advertised in OPTIONS, HSTS max-age too short (182 days, recommended 1 year), no API versioning (path is /api/activity, not /api/v1/activity), and X-Powered-By: Express disclosure.
For comparison with prior public-API case studies in this series:
- SWAPI: 4 findings, score 91 (A)
- Random User Generator: 12 findings, score 79 (B)
- Rick and Morty API: 9 findings, score 78 (B)
- PokéAPI: 12 findings, score 76 (B)
- FakeStoreAPI: 10 findings, score 75 (C)
- DummyJSON: 13 findings, score 75 (B)
- ReqRes: 17 findings, score 73 (C)
- JSONPlaceholder: 11 findings, score 73 (C)
- Bored API: 15 findings, score 75 (B)
Once you collapse the six SPA-fallback duplicates into a single 'unmatched paths return the docs SPA with HTTP 200' finding, Bored API's effective finding count drops to 10 — tighter than ReqRes and JSONPlaceholder, on par with FakeStoreAPI.
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.
Privileged endpoint accessible: /admin
/admin returned 200 without authentication. This may expose admin functionality.
Restrict access to admin/management endpoints. Implement RBAC with proper role checks.
Privileged endpoint accessible: /api/admin
/api/admin returned 200 without authentication. This may expose admin functionality.
Restrict access to admin/management endpoints. Implement RBAC with proper role checks.
Privileged endpoint accessible: /api/config
/api/config returned 200 without authentication. This may expose admin functionality.
Restrict access to admin/management endpoints. Implement RBAC with proper role checks.
Privileged endpoint accessible: /manage
/manage returned 200 without authentication. This may expose admin functionality.
Restrict access to admin/management endpoints. Implement RBAC with proper role checks.
Privileged endpoint accessible: /system/health
/system/health returned 200 without authentication. This may expose admin functionality.
Restrict access to admin/management endpoints. Implement RBAC with proper role checks.
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.
Debug endpoint accessible: /debug
A debug/diagnostic endpoint is publicly accessible — may leak internal state.
Disable debug endpoints in production. Restrict access to internal networks only.
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.
No authentication on any HTTP method
The endpoint returns success for GET and 5 other HTTP methods without authentication.
Implement authentication for all API endpoints. Consider OAuth 2.0, API keys, or JWT bearer tokens.
Missing security headers (3/4)
Missing: X-Content-Type-Options — MIME sniffing; X-Frame-Options — clickjacking; Cache-Control — sensitive response caching.
Add the following headers to all API responses: x-content-type-options, x-frame-options, cache-control.
Dangerous HTTP methods allowed: DELETE, PUT
The server advertises support for methods that can modify or delete resources.
Only expose HTTP methods that are actually needed. Disable TRACE, and restrict DELETE/PUT/PATCH.
HSTS max-age is too short
HSTS max-age is 15724800 seconds (recommended: 31536000 / 1 year).
Set Strict-Transport-Security max-age to at least 31536000 (1 year).
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.
Technology exposed via X-Powered-By: Express
The X-Powered-By header reveals framework details.
Remove the X-Powered-By header in production.
Attacker Perspective
An attacker has nothing to do against Bored API directly. The interesting question is what the SPA-catch-all pattern does to anyone copying the architecture into a real backend.
The SPA-catch-all pattern, generalized
Bored API's frontend is a small Vue/React docs page that lets you click 'Get Activity' in the browser and see a sample response. The Express server is configured so that any unmatched path serves index.html for client-side routing. This is the standard SPA hosting pattern — it makes the docs page work at any URL the user navigates to. The cost of this pattern is that every unmatched path returns 200 OK with HTML.
For Bored API the cost is zero — the docs page is public information. For a real backend, the cost is significant:
- The 'does this endpoint exist' question becomes unanswerable from outside the service.
HEAD /adminreturns 200 whether or not /admin is a real endpoint. - 404 monitoring (looking at access logs for unexpected 404s as a misconfiguration signal) stops working. Everything is 200.
- Endpoint enumeration scanners — like the BFLA probe in middleBrick that ran against this scan — flag every probed admin-named path as accessible. The signal-to-noise ratio collapses.
- Browser cache behavior gets weird. The SPA shell at
/api/adminis cached as if it were the API response, and when the SPA's actual/api/...XHR fires from inside the docs page, the cache layer can confuse them.
The fix is one line: serve the SPA only from the root and only for paths that don't start with an API prefix. Express's app.get('/', staticHandler) instead of app.get('*', staticHandler), with a separate app.use('*', (req, res) => res.status(404).json({error: 'Not found'})) at the bottom.
The 'mutating methods return 200' pattern
OPTIONS on /api/activity advertises GET, HEAD, POST, PUT, DELETE. The Allow header is what nginx generated by enumerating Express's defined route methods plus the catch-all. PUT and DELETE both return 200 with {"error":"Endpoint not found"} in the body. A consumer (or a CI check) seeing 200 on DELETE would reasonably conclude that the resource was deleted. The actual semantics are 'the catch-all sent you a 200 with an error string.' If you copy this Express setup into a real API where DELETE on a different path is implemented, your CI tests for the not-implemented paths will silently look successful.
The fix: explicit method handling. Express's app.all for any path that should reject mutating methods, returning 405 Method Not Allowed.
Analysis
Walking through each finding's underlying behavior:
1. CRITICAL — API accessible without authentication. Correct. Bored API is a public read-only service; no-auth is the intended posture. The scanner cannot tell from outside whether the API was meant to be authenticated, so this finding fires on every public mock.
2-6. HIGH — Privileged endpoint accessible (×5). The scanner walked /admin, /api/admin, /api/config, /manage, /system/health and got HTTP 200 on each. We confirmed the bodies manually:
$ curl -s https://bored.api.lewagon.com/admin
<!DOCTYPE html><html><head><meta charset=utf-8>...
<title>Bored API</title><meta name=robots content="noindex, nofollow">
...<script src=js/app.88a4eebeddf2ab369f76.js></script>...
$ curl -s https://bored.api.lewagon.com/wjelkjsdfjkldsf
[ identical 638-byte response ]Each of /admin, /manage, /system/health, and a randomly-typed bogus path returns the identical 638-byte SPA shell. /api/admin and /api/config return 30 bytes of {"error":"Endpoint not found"} instead — those two are at least correctly diagnosed as missing, just with the wrong status code (200 instead of 404). Five of the eight HIGH findings come from this BFLA cluster.
7. HIGH — CORS wildcard. Access-Control-Allow-Origin: *. Correct for a public service called from arbitrary tutorial origins. Wrong as a pattern to copy into a real API.
8. HIGH — Debug endpoint accessible. /debug returns the SPA shell, same as /admin. Same artifact, separate finding because the scanner tracks 'debug-named' paths separately from 'admin-named' paths. Six SPA-fallback findings in total, one root cause.
9. HIGH — Missing rate-limit headers. True. No X-RateLimit-*, no Retry-After. The maintainer almost certainly does enforce some kind of upstream rate limit (the service would have melted years ago otherwise) but it doesn't expose budget metadata in headers.
10. LOW — No auth on any HTTP method. Restatement of finding 1 at the method level. GET, HEAD, POST, PUT, DELETE all return without a 401.
11. LOW — Missing security headers (3 of 4). X-Content-Type-Options, X-Frame-Options, Cache-Control absent. HSTS is present (the next finding clarifies its issue). On a JSON API with no HTML attack surface this is hygiene; the SPA shell is the only HTML, and it has its own /noindex robots tag.
12. LOW — Dangerous methods allowed: DELETE, PUT. Advertised in OPTIONS. Both return 200 with an error body, not 405. Express catch-all behavior. Documented above.
13. LOW — HSTS max-age too short. 15,724,800 seconds is 182 days. The HSTS preload list requires 1 year minimum (31,536,000). This is a one-character config change at nginx.
14. LOW — No API versioning. Path is /api/activity, not /api/v1/activity. Correct observation, low-impact.
15. LOW — X-Powered-By: Express. Framework disclosure. One-line fix in Express: app.disable('x-powered-by').
Industry Context
Bored API sits in the same niche as JSONPlaceholder, Random User Generator, and the Studio Ghibli API — the 'I need a tiny live target for a fetch tutorial' category. Among that peer group it has the smallest response payload (about 130 bytes per call) and the simplest schema (seven flat fields, no nested objects, no embedded URL arrays). The data is not synthetic-credential-shaped, which is why the data-exposure heuristics did not fire. The service does exactly one thing.
The original Bored API's history is worth noting because it shapes the case study. Drew Thoennes built the service as a college-era project, ran it on a small VPS for years on his own dime, and finally retired it in 2024 when the cost outpaced his interest. That is the 'maintainer burnout' end state for every free public mock. Le Wagon picked up the schema and ran a near-identical mirror because the bootcamp uses Bored API in its curriculum and didn't want the tutorials to break. The mirror has been the canonical 'Bored API' since.
For consumers: cache responses, expect the service to disappear or change hands without notice, and don't build anything load-bearing on top of a free fan service. The original got retired with a few weeks' warning; the mirror could too.
Compliance: no PII in the responses, so GDPR, LGPD, CCPA, HIPAA are all out of scope. The wildcard CORS and missing rate-limit headers would be out-of-policy on any real e-commerce or fintech API but are not regulated for a fictional-activities mock.
OWASP API Top 10 2023 mapping for what this audit found: API1 Broken Authentication (intentional), API5 Broken Function-Level Authorization (the false-positive HIGH cluster from the SPA fallback), API8 Security Misconfiguration (CORS wildcard, missing rate-limit headers, missing security headers, short HSTS, X-Powered-By), API9 Improper Inventory Management (no versioning). Five of the ten categories touched. The remaining five (BOLA, Property-level Authorization, Unrestricted Resource Consumption, Unrestricted Access to Sensitive Business Flows, SSRF) did not fire because the surface is too small to produce them.
Remediation Guide
SPA catch-all returns 200 on every unmatched path
Scope the static-file handler to the root and an explicit SPA prefix; add a final JSON 404 handler. This collapses five false-positive 'admin endpoint accessible' findings to zero.
// Before (everything is 200):
app.get('*', (req, res) => res.sendFile('public/index.html'));
// After:
app.use('/api', apiRouter); // /api/* goes through API
app.get('/', staticIndex); // root serves the SPA shell
app.use(express.static('public')); // static assets
app.use((req, res) => res.status(404).json({ error: 'Not found' })); PUT and DELETE return HTTP 200 instead of 405
Define an explicit method router for /api/activity. Reject everything that isn't GET or HEAD with 405 Method Not Allowed and an Allow header.
app.route('/api/activity')
.get(getActivity)
.head(headActivity)
.all((req, res) => res
.status(405)
.set('Allow', 'GET, HEAD')
.json({ error: 'Method Not Allowed' })); Missing rate-limit headers
Use express-rate-limit with the IETF draft-7 standard headers. Whatever the policy is, expose it.
import rateLimit from 'express-rate-limit';
app.use(rateLimit({
windowMs: 60_000,
max: 200,
standardHeaders: 'draft-7', // emits RateLimit, RateLimit-Policy, Retry-After
legacyHeaders: false
})); X-Powered-By: Express disclosure
One line.
app.disable('x-powered-by'); HSTS max-age too short and missing security headers
Add helmet with explicit HSTS max-age and the other hardening headers. Suitable for a JSON API; CSP can stay off because the SPA docs page is the only HTML and has its own noindex.
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: false,
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
noSniff: true,
frameguard: { action: 'deny' }
})); Defense in Depth
If you maintain Bored API or any Express-backed public mock with a similar architecture, the high-leverage changes are:
1. Stop the SPA from being the catch-all for everything. The single most consequential cleanup is to scope the static-file handler to the root and a known SPA path prefix, then add an explicit JSON 404 at the bottom of the route table. Without this, every endpoint scanner that walks your service flags five-to-ten false-positive 'admin endpoint exists' findings, and your own log analysis loses the ability to distinguish typos from real attacks.
// Before (one wildcard catch-all):
app.get('*', (req, res) => res.sendFile('public/index.html'));
// After (scoped + explicit 404):
app.use('/api', apiRouter);
app.get('/', (req, res) => res.sendFile('public/index.html'));
app.use((req, res) => res.status(404).json({ error: 'Not found' }));2. Reject unimplemented methods explicitly. PUT and DELETE on /api/activity should return 405 Method Not Allowed, not 200 with an error JSON. Express:
app.route('/api/activity')
.get(getActivity)
.head(headActivity)
.all((req, res) => res.status(405).set('Allow', 'GET, HEAD').json({ error: 'Method Not Allowed' }));3. Emit rate-limit headers. Whatever the actual policy is, expose it. express-rate-limit with standardHeaders: 'draft-7' writes the standard RateLimit and Retry-After headers automatically.
4. Hygiene one-liners. app.disable('x-powered-by') closes the framework-disclosure finding. helmet({ contentSecurityPolicy: false }) closes the missing-security-headers finding. Bumping HSTS to max-age=31536000; includeSubDomains; preload closes the encryption-LOW.
For consumers — apps and tutorials calling Bored API — the defenses are: cache aggressively (the activity database is small and changes rarely), expect 200 OK to actually mean 'success' only when you also see a known-shape body, never copy the wildcard-CORS / SPA-catch-all / unimplemented-methods-return-200 patterns into a real backend, and treat any free public mock as a service that will be retired without notice (because the original Bored API was, and the mirror could be).
Conclusion
Bored API scored 75/100 with 15 findings. The number is misleading. Six of eight HIGHs are the same artifact: an Express SPA catch-all that returns the docs page (or a JSON 'Endpoint not found' at HTTP 200) on every unmatched path, including the admin-named ones the BFLA scanner probes. The actual finding set, deduplicated, is: no-auth (intentional), wildcard CORS (intentional for tutorial use), missing rate-limit headers (real, fixable), missing 3-of-4 security headers (cosmetic on a JSON API), HSTS too short (one-character fix), X-Powered-By (one-line fix), DELETE/PUT advertised but unimplemented (Express default behavior), no versioning (acceptable on a stable mock).
Nothing in the scan describes an exploitable risk against Bored API. The interesting analysis is how the SPA-catch-all pattern — present in countless tutorial-derived Express backends — collides with endpoint-discovery scanners and produces six false-positive HIGH-severity findings. If you copy this pattern into a real backend, you inherit a service where every wrong path looks like success, where 404 monitoring stops working, and where your security scanner's signal-to-noise ratio collapses. One scoped static-file handler and one explicit JSON 404 at the bottom of the route table fixes it.
The original Bored API was retired in 2024 and the domain no longer resolves. The Le Wagon mirror at bored.api.lewagon.com is the canonical live service today and is what we audited. Treat any free public mock the same way: cache aggressively, plan for the service to disappear, and don't carry its public-tutorial patterns into authenticated production code.