ADR-0018: Licensed content needs a short-lived content-access token¶
Status¶
Accepted
Date¶
2026-10-07
Context¶
CSL-255 sets the release baseline for protecting licensed C.S. Lewis text: a permanent app key
must not be enough to retrieve it, guests must keep reading without an account, and protected
responses must not be reusable through shared caches. Until now the only gate was the app key
from ADR-0005, which ships inside the app, can be extracted, and works forever. Every licensed
response was also Cache-Control: public.
Decision Drivers¶
- Must keep guest reading open: no account, no persistent guest profile (COPPA position).
- Must make the app key alone insufficient for licensed text.
- Must let a token be refused once it expires, with no server state to clean up.
- Should give rate limiting (CSL-258) a session to count against.
- Should stay independent of Firebase, so a sign-in problem never blocks reading.
Considered Options¶
Option A: Require Firebase sign-in for licensed text¶
- Pros: an identity to limit and ban.
- Cons: ends guest reading, which the product and the COPPA position rely on.
Option B: App attestation (App Check / App Attest / Play Integrity)¶
- Pros: proves a genuine app build.
- Cons: excluded from this release by CSL-255; a priced hardening step on top of tokens.
Option C: A signed, short-lived token minted with the app key¶
- Pros: no account; expires on its own; stateless; a random session id per token for rate limits; works for guests and signed-in readers alike.
- Cons: anyone with the app key can mint one, so it raises the bar for bulk extraction but relies on rate limits (CSL-258) to stop a scripted client.
Decision¶
We will use Option C.
POST /api/content-access/token/(app key only, no Firebase) returns{token, expires_at, expires_in}. LifetimeCONTENT_TOKEN_TTL_SECONDS, 15 minutes by default.- The token is a Django-signed value (HMAC-SHA256) under a dedicated salt, keyed by
CONTENT_TOKEN_SECRETorSECRET_KEY, with fallback keys for rotation. It carries a version, a random session id and an expiry. Nothing identifies a reader. - The app sends it as
X-Content-Token, separate from the FirebaseAuthorizationheader. HasContentAccesschecks it on every endpoint that returns licensed text or audio, after the app key. A route-inventory test fails until every API route is marked licensed or not.- Refusals are
401withContentTokenMissing,ContentTokenInvalidorContentTokenExpired. - Licensed responses become
Cache-Control: privatewith a boundedmax-age(API_PRIVATE_CACHE_SECONDS), so no shared cache can serve them past the token check. CONTENT_TOKEN_ENFORCEDgates refusing a missing token, so the server can ship before the app sends tokens. A token that is sent is always checked.
Consequences¶
Positive¶
- An extracted app key alone no longer returns licensed text once enforcement is on.
- Guests keep reading; no account or device profile is created.
- No table, no cleanup job; a token simply expires.
Negative¶
- ADR-0005's "the app key gates the API" gains a second layer on licensed endpoints.
- The app must mint, refresh and retry (CSL-261).
- Licensed responses lose CDN caching.
Risks & Mitigations¶
- Risk: a script mints tokens with an extracted app key. → Mitigation: rate limits on minting and on licensed reads per session (CSL-258); app attestation remains the priced next step.
- Risk: enabling enforcement before the app sends tokens breaks reading. → Mitigation: the flag is off in both buildspecs until CSL-261 ships.
Related¶
- ADR-0005: API conventions (the app-key gate this layers on).
- CSL-255 (baseline), CSL-256 (tokens), CSL-257 (enforcement), CSL-258 (rate limits), CSL-260 (cache policy), CSL-261 (mobile).