Authentication in the Contract
The contract does not implement authentication — it states which credential each consumer type presents, where it rides, how long it lives, and exactly what a 401 means. The mechanisms are Security Engineering's domain; the promises are yours.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Four credential shapes, matched to consumers
Authentication in an API contract is a matching problem, not a mechanism problem. The mechanisms — how OAuth flows work, how sessions are stored, how tokens are validated — are taught in Security Engineering (Authentication as a Lifecycle, OAuth 2.x — Delegated Authorization, Sessions). The contract decision is which credential each *consumer type* presents, because the shapes fit different callers: a server-side script cannot do a browser redirect dance, a third-party app must never see the user's password, and a browser cannot keep any secret at all.
Most real APIs need more than one, and the contract must say which surface takes which. The classic split: API Keys: Identity for Applications for server-to-server calls where the *application* is the principal; OAuth bearer tokens where a *user delegated* access to someone else's app; cookie sessions for your own first-party web client; and platform-issued workload identity (mTLS, signed platform tokens) between internal services. Offering only one forces the others into abuse — API keys embedded in mobile apps, or OAuth ceremony imposed on a cron job that just needs to fetch a CSV nightly.
| Consumer | Credential | Why it fits | Contract clauses it drags in |
|---|---|---|---|
| Partner server / script | API key (API Keys: Identity for Applications) | The app is the principal; no user, no redirect possible | Rotation, per-key Scopes: Least Privilege as Contract Surface, rate-limit identity |
| Third-party app acting for a user | OAuth 2.0 bearer token (OAuth 2.x — Delegated Authorization) | User consents without sharing a password; access is revocable and scoped | Token lifetime, refresh behavior, scope errors |
| First-party browser app | Session cookie (Sessions, Cookies and Their Attributes) | Browsers cannot hold long-lived secrets; cookies get HttpOnly + expiry machinery | CSRF posture, session lifetime, 401-vs-redirect |
| Internal service | Workload identity / mTLS | No human in the loop; platform can attest the caller | Identity propagation, which service may call what |
The 401 clause: expiry is normal, so its behavior is contract
Tokens expire on purpose — a bearer token that lives forever is a credential whose theft lives forever — which means every long-running consumer *will* hit 401 during normal operation, and their code path for it runs constantly, not exceptionally. That makes 401 semantics one of the most-executed clauses in your contract: 401 must mean "the credential is missing, expired or invalid — fix the credential and the same request will work", and a machine-readable error code must say *which*, because token_expired (refresh and retry) and token_revoked (stop; re-authorize the user) demand opposite client behavior.
Keep the boundary with 403 sharp: 401 is "who are you?" — fixable by the client with a better credential; 403 is "I know who you are, and no" — not fixable by retrying with the same identity (that side is Authorization Design in the Contract). APIs that return 403 for expired tokens, or 401 for permission failures, break every SDK's refresh loop: the client either refreshes forever against a permissions problem or re-prompts login for a missing scope. The distinction is also An Error Taxonomy Clients Can Branch On at work — these two codes are the ones auth middleware branches on unconditionally.
GET /v1/projects HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOi… ← expired 40s ago
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token",
error_description="access token expired"
Content-Type: application/json
Request-Id: req_01J9…
{
"error": {
"code": "token_expired",
"message": "Access token expired at 2026-08-25T09:14:03Z.",
"request_id": "req_01J9…"
}
}
← code says exactly which recovery applies:
token_expired → refresh, retry once
token_revoked → re-authorize, do not loopWhere the credential rides, and what you never do
Transport location is a contract clause with security consequences. The Authorization header is the right default: proxies and servers know not to log it, caches treat it as making the response private, and it never lands in a referrer. Credentials in query strings (?api_key=) end up in access logs, browser history, CDN logs and analytics — every system on the path becomes a credential store with no rotation policy. Custom headers (X-Api-Key) are acceptable and common; documented consistency matters more than the exact name.
Two anti-clauses complete the picture. Never accept credentials in the body or query "for convenience" alongside the header — every accepted location is a location you must secure and log-scrub forever. And never invent your own scheme (custom signature dances, homegrown token formats) when a boring standard fits: your consumers have libraries for Authorization: Bearer and HMAC; they have nothing for yours, and hand-rolled verification is where their bugs — and your support tickets — come from. The contract's ambition here is to be *unremarkable*: the value is in the precision of the clauses, not the novelty of the mechanism.
- One documented location —
Authorizationheader by default; every extra accepted location is permanent attack and logging surface. - Stated lifetimes — access-token TTL and refresh behavior are contract numbers consumers build their loops against.
- Machine-readable 401 causes —
token_expiredvstoken_revokedvsinvalid_credentials; recovery differs for each. - Standard schemes only — Bearer, Basic (over TLS, sparingly), HMAC; a custom scheme is a bug factory shipped to every consumer.
- TLS assumed everywhere — bearer credentials are replayable by anyone who reads them; there is no bearer auth without transport security.
Key points
- Authentication in the contract is a matching problem: API keys for app principals, OAuth for user-delegated access, sessions for first-party browsers, workload identity for internal services.
- Offering only one credential shape forces consumers into abuse — keys in mobile apps, OAuth ceremony on cron jobs.
- Token expiry is routine, so 401 handling is hot-path code: machine-readable causes (
token_expiredvstoken_revoked) determine opposite recoveries. - 401 means "fix the credential and retry"; 403 means "credential fine, permission denied" — confusing them breaks every SDK refresh loop.
- Credentials ride in the Authorization header, never query strings; each extra accepted location is permanent logging and attack surface.
- The mechanisms live in Security Engineering; the contract's job is stating credential type, location, lifetime and failure semantics precisely.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → contract: ships "authentication: pass your token" with no statement of type, location, lifetime or failure behavior.
- 2Consumers → integration: one puts the key in the query string, one in a custom header, one embeds it in a mobile app — the docs forbade none of it.
- 3Tokens → expiry: the provider shortens token TTL for security; every consumer's unhandled-401 path fires at once.
- 4Clients → retry loops: SDKs that got 403 for expired tokens refresh in a loop; ones that got 401 for missing scopes log users out.
- 5Provider → support: auth is now the top ticket category, and tightening anything breaks integrations that guessed differently.
- Credential leakage through logs and history when query-string auth was allowed — discovered at breach time, not integration time.
- Every consumer's session/refresh handling breaks differently when 401/403 semantics are inconsistent across endpoints.
- Security improvements (shorter TTLs, revocation) become breaking changes because failure behavior was never a documented clause consumers coded against.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Publish a credential matrix: consumer type → credential → location → lifetime → failure codes, on one page, before any endpoint docs.
- • Reserve 401 for credential problems and 403 for permission problems, with machine-readable causes distinguishing expired, revoked and malformed.
- • Accept credentials in exactly one location per scheme; reject query-string credentials outright rather than deprecating them later out of logs you cannot scrub.
- • Use standard schemes and ship the refresh-loop pseudocode in the docs — the loop every consumer must write is part of the contract's surface.
- • 401 rate by machine-readable cause: `token_expired` tracks normal churn; `invalid_credentials` spikes mean an attack or a broken consumer deploy.
- • Auth failures per consumer identity single out the partner whose rotation or refresh logic broke — before they open the ticket.
- • Scan your own access logs for credential-shaped query strings; finding any means a documented location has leaked into use.
- • New credential schemes (workload identity, mTLS for a new internal surface) add alongside old ones; each is a new documented row, not a replacement.
- • Shortening token lifetimes is behaviorally breaking for consumers without refresh loops — stage it with telemetry on who would fail, per [[consumer-driven-evolution]].
- • Killing a credential location (query-string keys) is a full deprecation program with per-consumer usage data, not a config flip.
- • Supporting multiple credential shapes multiplies documentation, test matrices and middleware paths — but one-shape APIs push the mismatch cost onto consumers as insecure workarounds.
- • Short token lifetimes shrink the theft window and increase refresh traffic and the blast radius of any auth-service wobble.
- • Strict single-location credential acceptance breaks quick-start ergonomics (`curl "…?key="` demos); the friction is the feature, but it costs adoption smoothness.