Operational Context
A cultural-heritage developer is securing the collection API that a museum’s public catalogue, a partner aggregator, and an internal cataloguing client all call. The same endpoints serve three very different things: freely reusable descriptive metadata, media bytes that are gated because the underlying work is still in copyright, and curatorial write operations that mutate the authoritative record. The failure this page fixes is the coarse token — one bearer credential that, once issued, reads everything and writes everything. The moment a public-facing client is compromised, an all-or-nothing token leaks restricted media and, worse, can rewrite accession records. This guide designs least-privilege OAuth2 scopes for a rights-tiered API, validates the token’s signature, audience, issuer, and expiry before trusting a single claim, and then enforces the object’s rights tier server-side so the client can never talk its way past an embargo. It sits inside the security boundaries for collection APIs stage and protects the persistence layer built in core architecture and collection taxonomy.
Root Cause Analysis
Three concrete mistakes turn a collection API into an over-exposure risk, and each has a single root cause rather than a scatter of unrelated bugs.
First, all-or-nothing tokens. When the authorization server mints one scope — or no scopes at all — every caller receives the union of every permission the API offers. A read-only catalogue widget then holds the same authority as a registrar’s editing client. Least privilege requires that the token carry only the permissions its client was granted, so a leaked public token can read open metadata and nothing more. The remedy is a small, orthogonal set of scopes that name distinct capabilities: reading descriptive metadata, reading restricted media, and writing objects are three separate grants, never one.
Second, skipped claims validation. A JWT is only trustworthy after its signature is verified against the issuer’s public key and its aud, iss, and exp claims are checked. A token minted for a different service — or by a different tenant’s authorization server — is a valid JWT that must still be rejected. Omitting the audience check lets a token intended for an unrelated API be replayed against the collection API; omitting the issuer check trusts any signer; accepting the none algorithm accepts a forged token outright. The root cause is treating decoding as parsing rather than as verification.
Third, client-side rights enforcement. If the API returns an object’s media because the client asked politely — or because the UI hid the download button — then the rights tier is not enforced at all. Copyright status, embargo state, and access tier must be evaluated on the server against the object being requested, independent of what scope the token carries. A token grants the capability to read restricted media in general; the server still decides, per object, whether this object’s tier permits it. Collapsing those two questions into one is how embargoed material leaks. The fix is a decision function that takes both the token’s scopes and the object’s rights tier and returns a single allow or deny.
Canonical Solution
The solution is two layers. A validation layer decodes the bearer token and rejects anything whose signature, audience, issuer, or expiry does not check out. An authorization layer maps the token’s scopes against the requested object’s rights tier and returns one deterministic decision. The scopes are deliberately narrow, and the rights tier is always consulted server-side — a token never carries an object’s access policy, only the caller’s capabilities.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import jwt # PyJWT
from jwt import InvalidTokenError
ISSUER = "https://auth.example-museum.org/"
AUDIENCE = "https://api.example-museum.org/collections"
class Scope(str, Enum):
COLLECTION_READ = "collection:read" # public descriptive metadata
MEDIA_READ_RESTRICTED = "media:read:restricted" # gated media bytes
OBJECT_WRITE = "object:write" # curatorial edits
class RightsTier(str, Enum):
OPEN = "open" # open metadata + media (public domain / CC0)
GATED = "gated" # in copyright: metadata public, media restricted
EMBARGOED = "embargoed" # dark until a future release date
class Decision(str, Enum):
ALLOW = "allow"
DENY_SCOPE = "deny:insufficient_scope" # authenticated but under-privileged → 403
DENY_TIER = "deny:rights_tier" # policy forbids it regardless of scope → 403
@dataclass(frozen=True)
class Principal:
subject: str
scopes: frozenset[str]
is_service_account: bool = False
def decode_token(token: str, *, key, algorithms=("RS256",)) -> Principal:
# Verification, not parsing: PyJWT checks the signature against the issuer key,
# rejects a wrong audience or issuer, and rejects an expired token. Pinning the
# algorithm list refuses an 'alg: none' or HS/RS downgrade forgery outright.
claims = jwt.decode(
token,
key=key,
algorithms=list(algorithms),
audience=AUDIENCE, # reject a token minted for another API
issuer=ISSUER, # reject a token from an untrusted signer
options={"require": ["exp", "aud", "iss", "sub"]},
)
raw = claims.get("scope", "")
scopes = frozenset(raw.split()) if isinstance(raw, str) else frozenset(raw)
return Principal(
subject=claims["sub"],
scopes=scopes,
# A machine-to-machine token from the client-credentials grant has no end user.
is_service_account=claims.get("gty") == "client_credentials",
)
# The tier decides which read scope is *sufficient* for an object's media. Writing is a
# separate axis (OBJECT_WRITE) and is never implied by any read scope.
_MEDIA_REQUIREMENT: dict[RightsTier, Scope] = {
RightsTier.OPEN: Scope.COLLECTION_READ,
RightsTier.GATED: Scope.MEDIA_READ_RESTRICTED,
RightsTier.EMBARGOED: Scope.MEDIA_READ_RESTRICTED,
}
def authorize_read(principal: Principal, tier: RightsTier, want_media: bool) -> Decision:
# 1. An active embargo is dark server-side: no scope buys access the policy has not
# released. This is the check a client can never override from its side.
if tier is RightsTier.EMBARGOED:
return Decision.DENY_TIER
# 2. Descriptive metadata for open and gated objects needs only the base read scope —
# the rights tier gates the media, not the catalogue record.
if not want_media:
allowed = Scope.COLLECTION_READ.value in principal.scopes
return Decision.ALLOW if allowed else Decision.DENY_SCOPE
# 3. Media bytes: open media needs the base scope, gated media needs the restricted
# scope. The object's tier — not the request — selects the required scope.
required = _MEDIA_REQUIREMENT[tier]
return Decision.ALLOW if required.value in principal.scopes else Decision.DENY_SCOPE
def authorize_write(principal: Principal, tier: RightsTier) -> Decision:
# Curatorial writes are the same regardless of tier: they require OBJECT_WRITE and
# nothing else grants it. A read token, however broad, can never mutate a record.
return Decision.ALLOW if Scope.OBJECT_WRITE.value in principal.scopes else Decision.DENY_SCOPEWired into a framework, the same two layers become one dependency the route declares. The dependency turns a validation failure into 401 Unauthorized and an authorization failure into 403 Forbidden — a distinction that matters, because the two tell the client different things: re-authenticate versus request a broader grant.
from fastapi import Depends, Header, HTTPException, status
def _bearer(header: str) -> str:
scheme, _, token = header.partition(" ")
if scheme.lower() != "bearer" or not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "expected a Bearer token")
return token
def _public_key() -> str:
# Injection point: in production, fetch and cache the issuer's JWKS and select the
# signing key by the token's `kid` header. Kept separate so tests supply a known key.
raise NotImplementedError("wire to your cached JWKS client")
def require_read(tier: RightsTier, want_media: bool = False):
def _dep(authorization: str = Header(...)) -> Principal:
try:
principal = decode_token(_bearer(authorization), key=_public_key())
except InvalidTokenError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) # bad token → 401
match authorize_read(principal, tier, want_media):
case Decision.ALLOW:
return principal
case _:
raise HTTPException(status.HTTP_403_FORBIDDEN, "insufficient scope for rights tier")
return _depBecause decode_token verifies before returning and authorize_read consults the object’s tier every call, no path exists where a client’s assertion — rather than the server’s policy — decides access. The request flows through the four checks below in order, and any one of them can short-circuit to a deny.
Edge Cases and Variants
- Public metadata versus embargoed media. An
OPENobject serves both its record and its media to acollection:readtoken; aGATEDobject serves the record to the same token but requiresmedia:read:restrictedfor the bytes; anEMBARGOEDobject denies everything server-side until release, no matter what scope the token holds. The release itself is a scheduling concern, covered in routing embargo dates to access tiers. - Service accounts. A machine-to-machine token from the client-credentials grant has no end user, so it should be issued the narrowest scope its job needs — usually
collection:readfor a harvester, neverobject:write. Flaggingis_service_accountlets an audit log distinguish an automated read from a curator’s action even when both carry the same scope. - Scope escalation attempts. A client cannot widen its own token: scopes are asserted by the authorization server at issue time and are signed into the JWT, so a tampered
scopeclaim breaks the signature and failsdecode_token. Reject any request that presents a scope the client was never granted rather than silently down-scoping it. - Short-lived tokens and refresh. Keep access tokens short (5–15 minutes) so a leaked token expires quickly, and let clients present a refresh token to the authorization server for a new one. The API validates only
expon the access token; rotation and revocation live at the authorization server, not in the resource server. - Strict versus lenient audience. In a single-API deployment, pin
audto one exact string. Where one authorization server fronts several resource servers, validate that the API’s identifier is a member of a list-valuedaudclaim — but never disable the check to make a mismatched token work.
Validation
Prove the two invariants that matter — a token missing the required scope is denied, and a token minted for the wrong audience is rejected before any claim is trusted — with a test that signs its own tokens and needs no network. It uses HS256 with a local secret purely so the test is self-contained; production verifies RS256 against the issuer’s public key.
# test_scoping.py -> run with: pytest -q test_scoping.py
import time
import jwt
import pytest
SECRET = "test-only-hs256-key"
def _mint(scope: str, *, aud: str = AUDIENCE, iss: str = ISSUER, ttl: int = 300) -> str:
payload = {"sub": "client-1", "scope": scope, "aud": aud, "iss": iss,
"exp": int(time.time()) + ttl}
return jwt.encode(payload, SECRET, algorithm="HS256")
def _principal(scope: str) -> Principal:
return decode_token(_mint(scope), key=SECRET, algorithms=["HS256"])
def test_missing_scope_denies_gated_media():
p = _principal("collection:read") # base scope only
assert authorize_read(p, RightsTier.GATED, want_media=True) is Decision.DENY_SCOPE
def test_wrong_audience_is_rejected():
tok = _mint("collection:read", aud="https://api.other-museum.org/")
with pytest.raises(jwt.InvalidAudienceError): # never reaches authorization
decode_token(tok, key=SECRET, algorithms=["HS256"])
def test_expired_token_is_rejected():
tok = _mint("collection:read", ttl=-10)
with pytest.raises(jwt.ExpiredSignatureError):
decode_token(tok, key=SECRET, algorithms=["HS256"])
def test_base_scope_reads_metadata_but_not_gated_media():
p = _principal("collection:read")
assert authorize_read(p, RightsTier.GATED, want_media=False) is Decision.ALLOW
assert authorize_read(p, RightsTier.GATED, want_media=True) is Decision.DENY_SCOPE
def test_embargo_denies_even_with_media_scope():
p = _principal("media:read:restricted collection:read")
assert authorize_read(p, RightsTier.EMBARGOED, want_media=False) is Decision.DENY_TIER
def test_read_scope_cannot_write():
p = _principal("collection:read media:read:restricted")
assert authorize_write(p, RightsTier.OPEN) is Decision.DENY_SCOPEA green run confirms that under-privileged tokens are denied rather than served, that a wrong-audience or expired token is rejected at the validation boundary before any authorization runs, that the base scope reads metadata but not gated media, that an embargo holds regardless of scope, and that no read scope can ever mutate a record.
Standards Alignment
The three rights tiers are not arbitrary — they map onto the machine-readable statements a museum already assigns. An OPEN object typically carries a public-domain or no-known-copyright RightsStatements.org URI, a GATED object an in-copyright statement, and an EMBARGOED object a restricted status with a future release. Deriving the API’s access tier from the same URI that drives the rest of the pipeline keeps one source of truth; that mapping is worked through in mapping RightsStatements.org to collection fields. The object record whose rights field the tier reads is the JSON-LD document modelled in designing museum object schemas, so the resource server evaluates the tier against the persisted record, never against a client hint. OAuth2 itself supplies the bearer-token and scope model; the resource server’s job, described here, is to verify the token and enforce the object’s rights tier — the two halves that together make least privilege real.
FAQ
Why split reading into collection:read and media:read:restricted instead of one read scope?
Because descriptive metadata and restricted media have different exposure risk. A public catalogue widget legitimately reads open metadata for every object, but it should never hold the authority to pull the media bytes of an in-copyright work. Two scopes let the public client be granted only the first, so a leak of that token cannot exfiltrate gated media. One scope would collapse both capabilities into a single credential and defeat least privilege.
If the token carries the right scope, why still check the rights tier server-side?
Because a scope is a statement about the caller’s capability, not about this object’s policy. A media:read:restricted token says the client may read restricted media in general; the server still must decide whether the specific object is embargoed, and an embargo overrides any scope. Enforcing the tier on the server against the persisted record is the only place a client cannot bypass it by editing a request.
Should the API validate the token itself or call the authorization server on every request?
Validate locally. A signed JWT lets the resource server verify the signature, audience, issuer, and expiry against the issuer’s cached public key with no network round-trip, which keeps latency low. Reserve a call to the authorization server for token introspection of opaque (non-JWT) tokens or for near-real-time revocation checks on high-value write scopes.
How short should access tokens live, and where does refresh fit?
Keep access tokens short — 5 to 15 minutes — so a leaked one expires before it is very useful, and let clients exchange a longer-lived refresh token at the authorization server for a fresh access token. The collection API only ever validates the access token’s exp; issuing, rotating, and revoking tokens are the authorization server’s responsibility, not the resource server’s.
Related
- Security Boundaries for Collection APIs — parent access-control stage
- Core Architecture & Collection Taxonomy — persistence and taxonomy overview
- Designing Museum Object Schemas — the record the tier reads from
- Mapping RightsStatements.org to Collection Fields — deriving the access tier
- Routing Embargo Dates to Access Tiers — releasing embargoed objects on schedule