01
What a token actually is, before what it does
The plain explanation
RFC 6749 — the OAuth 2.0 specification, published October 2012 — defines an access token like this [1]:
“Access tokens are credentials used to access protected resources. An access token is a string representing an authorization issued to the client. The string is usually opaque to the client. The token may denote an identifier used to retrieve the authorization information or may self-contain the authorization information in a verifiable manner (i.e., a token string consisting of some data and a signature).”
RFC 6749 · The OAuth 2.0 Authorization Framework · §1.4 · fetched , rfc-editor.org
Two words in that quote carry the whole lecture. String: a token is text. It is not a session, not a connection, not a login. It is a sequence of characters you put in a header. Opaque: in the common case you cannot read anything out of it, and you are not expected to. The server that issued it holds the mapping from that string to a real set of permissions.
The same section says what the abstraction is for:
“The access token provides an abstraction layer, replacing different authorization constructs (e.g., username and password) with a single token understood by the resource server. This abstraction enables issuing access tokens more restrictive than the authorization grant used to obtain them, as well as removing the resource server's need to understand a wide range of authentication methods.”
RFC 6749 · §1.4 · fetched , rfc-editor.org
Read that sentence twice, because it is the reason OAuth exists at all. Instead of handing your password to every service that needs your data, you hand them a token that is narrower than your password: shorter lifetime, fewer permissions. The password proves who you are; the token only proves what you allowed this particular client to do, for a while.
The second concept is bearer semantics, defined in RFC 6750 (The OAuth 2.0 Authorization Framework: Bearer Token Usage, October 2012) [3]. A bearer token works for whoever presents it. There is no second factor built into the string itself — the possession is the authority.
Jargon translationA bearer token is a claim ticket: whoever walks up with the ticket gets the coat. That is a clean, useful property when the alternative is passing a password around a network of services, but it has one hard consequence: anywhere the token is written, it must be treated as the secret itself. Not “a secret reference”. The thing.
In practice you send it in a request header:
GET /v1/sessions?meeting_key=1296 HTTP/1.1
Host: api.openf1.org
accept: application/json
Authorization: Bearer <access_token>
That header format is RFC 6750's “Authorization Request Header Field” method, and it is what every OpenF1 endpoint expects. When something goes wrong with it, the server is required to tell you so in a specific way. RFC 9110 §15.5.2 is unambiguous about the 401 response [5]:
“The 401 (Unauthorized) status code indicates that the request has not been applied because it lacks valid authentication credentials for the target resource. The server generating a 401 response MUST send a WWW-Authenticate header field containing at least one challenge applicable to the target resource.”
RFC 9110 · HTTP Semantics · §15.5.2 · fetched , rfc-editor.org
Verified live this run: OpenF1's /token endpoint, given a bad username and password,
returns
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
content-type: application/json
{"detail":"Incorrect username or password"}
Two things to notice. The WWW-Authenticate: Bearer header is RFC 9110's MUST, and
OpenF1 complies. And the body says something specific — “Incorrect username or password”, not a blank
page. That body is going to matter enormously in §09; for now, just notice that a well-behaved API puts
its diagnosis in the body even when the status code is a single digit-level shrug.
One more mechanical fact, and then we can talk about expiry. RFC 6749 §5.1 requires that the response carrying a token must not be stored by any cache:
“The authorization server MUST include the HTTP ‘Cache-Control’ response header field with a value of ‘no-store’ in any response containing tokens, credentials, or other sensitive information.”
RFC 6749 · §5.1 · fetched , rfc-editor.org
That is a testable property. If you ever inspect a token endpoint response and it does not
carry Cache-Control: no-store, that is a finding. A token that lands in a shared cache is a
token that is being handed out to the next requester.
023600 s
Two tokens, two lifetimes — and the question that decides everything
The plain explanation
OAuth does not have “a credential”. It has a small family [15], and the two that matter here are the access token and the refresh token.
RFC 6749 §1.5 defines the second one:
“Refresh tokens are credentials used to obtain access tokens. Refresh tokens are issued to the client by the authorization server and are used to obtain a new access token when the current access token becomes invalid or expires, or to obtain additional access tokens with identical or narrower scope. Issuing a refresh token is optional at the discretion of the authorization server.”
RFC 6749 · §1.5 · fetched , rfc-editor.org
And then the sentence that explains why refresh tokens are handled differently from access tokens in every architecture you will ever read:
“Unlike access tokens, refresh tokens are intended for use only with authorization servers and are never sent to resource servers.”
RFC 6749 · §1.5 · fetched , rfc-editor.org
Sit with the shape of that. The access token goes outward — to the API you want data from. The refresh token goes backward — only to the authorization server that issued it, over its own endpoint. A refresh token that has been sent to a resource server has already been misused. If you ever find a refresh token in a request header aimed at an API host, something is wrong with the design, not with the request.
The flow, in the order it happens:
1. client -> authorization server : here is my grant (a code, a password, a key)
2. authz -> client : access_token (expires in N seconds) [+ refresh_token]
3. client -> resource server : GET /something Authorization: Bearer <access_token>
4. resource-> client : the data ... repeat until expiry
5. resource-> client : 401 / invalid_token <-- access token is dead
6. client -> authorization server : POST /token grant_type=refresh_token&refresh_token=...
7. authz -> client : new access_token [+ possibly a new refresh_token]
Steps 3–5 are the loop that runs thousands of times a day and never surprises anyone. Step 6 is the one piece of plumbing that decides whether your stack is self-healing or whether it needs a human at 2 a.m.
RFC 6749 §5.1 defines the field that tells you how long step 3–5 can run:
“expires_in — RECOMMENDED. The lifetime in seconds of the access token. For example, the value ‘3600’ denotes that the access token will expire in one hour from the time the response was generated.”
RFC 6749 · §5.1 · fetched , rfc-editor.org
Note the phrase from the time the response was generated. Not “from the time you received it”. We will come back to that in §05, because it is where clock skew lives.
So: access token, short; refresh token, longer; both eventually expire. RFC 6749 §1.5 says issuing a refresh token is optional — some servers never do. That gives you exactly two shapes in the world:
| Shape | What expires | Who can mint the next one | Failure looks like |
|---|---|---|---|
| Refreshable (code+PKCE, device code, client credentials) | access token every N seconds/minutes | a machine holding the refresh token (or client secret) | a silent 401 that a refresh call repairs |
| Re-mintable (password grant) | access token every N seconds | a machine holding the root secret — the password | silent 401, repaired by re-POSTing the credential |
| Human-only | the refresh token / the grant itself | only the resource owner, interactively | AGENT_AUTH_REQUIRED, invalid_grant, a dead pipeline |
Notice that the first two rows are different mechanisms that produce the same operational result: they self-heal. The third row is the one this lecture exists for.
03600 s
Case A: the expiry that self-heals — pitwall and OpenF1
re-mint, then end, then the next one
Notches, left to right: RFC 9700's standard margin (60 s), ours (600 s) — one decade, one rung — and the re-mint point (3000 s). Ink after the close tick: something mints the next one. The gap between the re-mint notch (35.39 %) and the end (36.52 %) is 1.13 % of this axis and 16.67 % of the hour.
The mechanism, read off the box
/opt/pitwall/live_feed.py [17] is a single Python
file, run as the pitwall-live systemd service, that holds the OpenF1 credentials and writes
one JSON file for a dashboard to fetch. The relevant constants:
CRED_FILE = "/root/.hermes/secrets/openf1.env"
OUT = "/var/www/dllama.tech/f1/live.json"
MEETING_KEY = 1296 # 2026 Singapore GP
BASE = "https://api.openf1.org"
TOKEN_URL = BASE + "/token"
POLL_S = 10 # seconds between polls
TOKEN_TTL = 50 * 60 # 50 minutes, against a 3600-second token
and the credential mint:
def load_creds():
c = {}
for line in open(CRED_FILE):
line = line.strip()
if "=" in line:
k, v = line.split("=", 1); c[k.strip()] = v.strip()
return c["OPENF1_USERNAME"], c["OPENF1_PASSWORD"]
def get_token(u, p):
body = urllib.parse.urlencode({"username": u, "password": p}).encode()
r = urllib.request.urlopen(urllib.request.Request(
TOKEN_URL, data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"}), timeout=30)
return json.loads(r.read().decode())["access_token"]
and the loop's only expiry logic:
if
if not tok or time.time() - tok_ts > TOKEN_TTL:
tok = get_token(u, p); tok_ts = time.time(); schedule = None
That is the entire credential lifecycle: mint on first use, and re-mint once the token you are
holding is older than 50 minutes. There is no retry ladder, no exponential backoff, no refresh
endpoint. The mechanism is one if.
Why 50 and not 60
The token is valid for 3600 seconds — 60 minutes. The poller replaces it at 3000 seconds — 50 minutes.
The 10-minute gap is the design. Three things can eat into a token's remaining life:
- Network latency on the mint itself.
expires_in: 3600is measured from when OpenF1 generated the response, per RFC 6749 §5.1 [1]. By the time the client has parsed it, some seconds are already spent. - Clock difference between the client and the issuer. If the client's clock runs a minute fast, it will believe the token is valid for a minute it does not have. (§05.)
- The request that is already in flight. The poller is doing something every 10 seconds. If the refresh happened exactly at expiry, the request that arrived at second 3601 would be a 401 — not a catastrophic failure, but a spurious one, and spurious failures are what erode trust in a monitor.
The 10-minute margin converts “refreshes exactly at expiry” (a race) into “refreshes with 10 minutes of slack” (no race). That choice costs nothing but 10 minutes of a free resource. This is the cheapest reliability decision in the entire stack, and it is the pattern to copy:
Expire early, on purpose, by a margin you chose.
The rest of the poll, because it is also credential discipline
def api_get_opt(path, tok):
"""Like api_get but returns [] when OpenF1 404s (no data for that query yet)."""
try:
return api_get(path, tok)
except urllib.error.HTTPError as e:
if e.code == 404:
return []
raise
A 404 from an endpoint that has no events yet (/v1/pit before the first stop,
/v1/overtakes before the first pass) is turned into an empty list. A 401 is not
handled here — it propagates. That is the correct split, and §09 is about why: 404 means “nothing to
show”, 401 means “your credential is not working”, and only one of those is something you can paper
over.
The write is atomic — a .tmp file followed by os.replace() — so a browser
reading /f1/live.json gets either the previous state or the new one, never half of each.
And when no session is live, the service writes a standby state carrying the next session's
name and start time rather than crashing or writing nothing. Credential lifecycle and consumer lifecycle
are separate concerns here, which is why the page survives between races.
Verified this run: /var/www/dllama.tech/f1/live.json, written 14:34Z, was in standby —
the Singapore Grand Prix race window ran 12:00Z–14:00Z and had just closed.
What still needs a human in case A
Self-healing has a boundary, and it is worth naming precisely. The pitwall repairs the access token. It cannot repair:
- a changed password.
load_creds()reads two strings from a 600 file. Change the OpenF1 password and the poller starts returning 401 from/token— verified this run, that response is401 {"detail":"Incorrect username or password"}withwww-authenticate: Bearer. - a lapsed subscription. Same shape, same endpoint, same 401.
- a revoked account. Same.
In all three, the thing that broke is the root secret — the one a human created by subscribing. There is no process that can re-create it, because creating it is a purchase and a login at OpenF1, and a purchase is not something a cron job is allowed to do. Everything downstream of the root secret self-heals; the root secret itself is human territory. That is true of every credential in every stack you will ever run. The mistake is not having human-only credentials. The mistake is not knowing which ones they are.
0449 d
Case B: the expiry that needs a human — Claude Code on oduser
refresh token expired
dead since
Nothing after the close tick but the tail. No retry, no backoff and no cron reaches past it — the span is measured to this page's stamp, , and it is still running.
What happened
Open Design is a build pipeline that drives Claude Code [13]
headlessly as a dedicated user, oduser, to produce the HTML pages for this course. In the
week of the pipeline started failing immediately, with this failure shape, recorded in the
run diagnostics [19]:
failureCategory: auth
failureAction: login
"Claude Code could not authenticate. Run `claude`, use `/login`, then retry."
errorCode: AGENT_AUTH_REQUIRED
The probes taken at the time established the timeline for oduser's Claude credentials:
the refresh token expired , and the access token expired
. Because each access-token mint requires a live refresh token, the access
token could not have been renewed after 19:40, and by 22:40 both were dead.
The fix was not a retry, not a backoff, not a longer timeout, not a cron job. The fix, as written into the operations notes for that pipeline, was:
# headless login flow, as the daemon user, via a PTY:
su - oduser -s /bin/bash -c 'claude auth login --claudeai'
# -> prints an authorize URL
# -> a HUMAN opens it, authenticates, and pastes the resulting code back
Weeks later, running a probe against that pipeline still returned AGENT_AUTH_REQUIRED.
This is the purest possible example of the third row of the table in §02. Nothing in the automation
could repair it, because repairing it required a browser session, a human's consent, and a code pasted
back into a terminal. Three of this course's weekly lectures were built by hand, outside the pipeline,
because one credential was waiting on a person.
Why nothing automated could fix it
It is tempting to think a better retry policy would have worked. Write out the loop and you can see it cannot:
- The poller tries to use the access token. It is dead.
401. - The client tries the refresh endpoint with the refresh token. The refresh token is also
dead. The server returns an error — in Google's equivalent (below) it is
invalid_grant[12]. - A retry sends the same dead refresh token. The server returns the same error. The error is not transient. There is no backoff interval at which a server-side expiry becomes unexpired.
Every automated repair assumes the broken thing is stale state you can refresh — a cached value, a dropped connection, a rate limit that clears. A dead grant is not stale state. It is a decision by the authorization server that the authorisation no longer exists, and the only way to create a new one is the interactive consent flow that creates authorisations in the first place.
RFC 9700 (Best Current Practice for OAuth 2.0 Security, BCP 240, January 2025 — the document that updates RFC 6749 and RFC 6750) explains why servers are configured to do this [4]:
“Refresh tokens SHOULD expire if the client has been inactive for some time, i.e., the refresh token has not been used to obtain fresh access tokens for some time. The expiration time is at the discretion of the authorization server. It might be a global value or determined based on the client policy or the grant associated with the refresh token (and its sensitivity).”
RFC 9700 · BCP 240 · §4.14 · fetched , rfc-editor.org
Your stack has an inactivity deadline you never configured, chosen by a vendor, on a credential you believe is “saved”. A login you completed once and have not used for a while is not a login that is waiting for you. It is a login that has already expired, silently, because nothing tells you when a credential is unused.
The same failure, different vendor
Google Drive's invalid_grant on the same box has been dead since
— recorded in the vault [20] and never
repaired, for exactly the same reason and with exactly the same shape of fix: a human must re-run the
consent flow. Two different vendors, two different products, one identical lesson. This is not an
Anthropic quirk or a Google quirk. It is what OAuth does when the consent grant is gone.
The honest complication: AGENT_AUTH_REQUIRED is ambiguous
Here is the part that makes this a genuinely hard engineering problem rather than a “just add a check” problem. The pipeline's own diagnostics reference says, as its first heading:
“## AGENT_AUTH_REQUIRED does not mean the token is dead”
Open Design bridge · od-run-failure-diagnostics.md · first heading · read on this host
The documented discriminator is a real probe rather than a status check:
# a real prompt as the daemon user — NOT just `claude auth status`,
# because a stale access token can still show loggedIn:true while 401ing on actual use
su - oduser -s /bin/bash -c '/opt/node24/bin/claude -p "Reply with exactly: pong" \
--dangerously-skip-permissions --max-turns 1'
# expect: pong
If that returns pong, the failure was transient; retry with no human. If it does not,
the grant is dead and only a person can restore it. The lesson is not “the error code told you it needed
a human”. It is: an auth failure is ambiguous by default, and your stack needs a probe that
converts it into a decision. Without the probe you get one of two bad behaviours — retry
forever against a dead grant, or wake a human for a token that would have fixed itself on the next
call.
0560 s
The expiry plumbing you have to get right: rotation, absolute vs sliding, clock skew
Refresh token rotation, and the race it creates
RFC 6749 §10.4 describes the countermeasure against a stolen refresh token [1]:
“For example, the authorization server could employ refresh token rotation in which a new refresh token is issued with every access token refresh response. The previous refresh token is invalidated but retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach.”
RFC 6749 · §10.4 · fetched , rfc-editor.org
RFC 9700 §4.14.2 makes it normative for public clients, and adds the operational consequence [4]:
“Refresh token rotation: the authorization server issues a new refresh token with every access token refresh response. The previous refresh token is invalidated… This stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant.”
RFC 9700 · BCP 240 · §4.14.2 · fetched , rfc-editor.org
Read the last clause carefully. Rotation means a refresh token is single-use. Which means that if two processes in your stack both hold a copy of the refresh token and both try to use it, the second one presents an already-invalidated token, and — by the design above — the authorization server treats that as evidence of a breach and revokes the active refresh token too. Two honest processes racing = a revoked grant = a human needed.
This is not hypothetical in an agent stack. The moment you have a gateway plus a CLI plus a cron job plus subagents all sharing one OAuth credential, you have several processes that all want to refresh one single-use token.
The Hermes credential-pool documentation describes the fix in exactly these terms — a file lock plus adoption [9]:
“Across processes (many subagents, a gateway plus a CLI, cron jobs), OAuth refreshes are serialized through a file lock on
Hermes · Credential Pools · user guide · fetchedauth.json. When one shared OAuth grant expires under many concurrent processes, exactly one process performs the refresh; the others detect that the on-disk token no longer matches the one that failed and adopt it instead of rotating the single-use refresh token again. A process that loses the lock race keeps its entry healthy and retries — lock contention is never recorded as a credential failure.”
Three design rules are visible in that paragraph, and they generalise beyond Hermes:
- Serialize the refresh. One writer at a time. Otherwise single-use tokens turn your own concurrency into a breach signal.
- Adopt, don't re-mint. If the token on disk changed while you were working, the correct action is to read the new one, not to run the refresh again.
- Never count lock contention as a credential failure. Because if you do, rotation + concurrency will bench a perfectly healthy credential, and your pool will empty itself in a loop.
Absolute vs sliding expiry
Two expiry models, and you must know which one you are on:
- Absolute expiry. The credential dies at a fixed instant computed when it was issued. It does not matter whether you used it every minute or not at all. A 90-day absolute refresh token issued on dies at 19:40 on + 90 days, and there is nothing you can do to extend it.
- Sliding expiry. Each successful use pushes the deadline out. Use it daily and it never dies; leave it a month and it lapses.
RFC 9700 §4.14.2's inactivity rule is a sliding model with a floor — which tells you
something practical and slightly counterintuitive: a credential your stack uses constantly may
live longer than one it uses rarely, on the same provider. The failure arrives not when you are
hammering the API but after a quiet period. The oduser refresh token dying is exactly this
shape: the pipeline was idle between builds, and idle is what the expiry timer counts.
The operational translation is a single rule: for every long-lived credential, know which model it is on, and schedule a use of it that is frequent enough to stay inside the window. A weekly build that refreshes the token is not just a build — it is the heartbeat that keeps the grant alive. If you turn the build off for a month, you may turn the credential off too.
Clock skew
expires_in is defined relative to the server's moment of generation (RFC 6749
§5.1, quoted in §02). Your process knows only when it received the response. Between those two
moments sit network latency and the difference between two clocks.
HTTP has a mechanism for this. RFC 9110 §6.6.1 requires that an origin server with a clock send a
Date header on 2xx, 3xx, and 4xx responses, and says what to do when it is missing or wrong
[5]:
“A recipient with a clock that receives a response message without a Date header field MUST record the time it was received and append a corresponding Date header field to the message's header section if it is cached or forwarded downstream.”
“A recipient with a clock that receives a response with an invalid Date header field value MAY replace that value with the time that response was received.”
RFC 9110 · HTTP Semantics · §6.6.1 · fetched , rfc-editor.org
In other words: the protocol's own fallback is “trust your own receipt time”, which is precisely the approximation that introduces skew. Three practical rules follow:
- Compute the deadline once, at mint time, as an absolute timestamp —
expires_at = received_at + expires_in - margin— and store the result. Do not recompute it later from a storedexpires_inand a.now()that may have moved. - Subtract a margin. 60 seconds is standard; the pitwall's is 600 (the 50-vs-60-minute gap). The margin absorbs latency, skew, and the in-flight request.
- Never assert validity to the second. A check that says “valid until 14:59:59 and it is now 14:59:58” is a coin flip with extra steps. Treat anything inside the margin as already expired.
06
Making the token store observable
A credential you cannot inspect is a credential you will discover is dead at the worst possible moment: inside the failure it was supposed to prevent. The pattern that avoids this is small and boring.
- Store the deadline, not just the secret. A store that holds
token = "abc…"cannot answer “is this alive?” without a network call. A store that holdstoken,expires_at,issued_at, andprobed_atcan answer it instantly, offline, and can print it in a status line. - Health checks report remaining life, not just success. “expires in 6 days” is a control. “ok” is not — “ok” is true right up until it is false, and then it is useless. The most valuable monitoring signal for any credential is a countdown, because a countdown is the only version of the fact that you can act on before the failure.
- Probe with a real call, not a presence check. The diagnostics note for the Open
Design pipeline [19] is blunt about this: a stale access token can
still report
loggedIn:truewhile failing on actual use. File exists ≠ file valid. The Hermes CLI exposes exactly this idea in its credential-pool commands [9], where a refresh “proves the grant is alive” and returns the credential to rotation, and the status commands (hermes auth list) are careful to be peeks — they report state without themselves refreshing, rotating, or benching anything, so that looking at a credential cannot damage it. - Log the body, not just the code. Compare these two log lines.
openf1: 401
openf1: 401 {"detail":"Incorrect username or password"}
The first one leads to “the API is broken”. The second one leads to “the credential file has the wrong password in it” — which is a five-second fix instead of an afternoon. RFC 9110's own text on the 4xx class [5] says the server “SHOULD send a representation containing an explanation of the error situation, and whether it is a temporary or permanent condition”. Servers comply often enough that dropping the body is throwing away free diagnosis.
Log it. Store the first body you get, not the thousandth.
- Distinguish “absent” from “present but dead”. These are different alarms with different responses. No credential file = a configuration problem, fix it in five minutes. Credential file present, provider rejects it = a human is required, start asking now.
- Never let the token sit in a cache. RFC 6749 §5.1
[1] requires
Cache-Control: no-storeon token responses. The same logic applies to your own storage: a token in a shared cache, a log file, a shell history (which is why the credential goes in a file read by the process, never in acurl -d "password=…"on a command line), or a browser'slocalStorageis a token that has escaped its container — which is also why it matters exactly what a child process does and does not inherit [10].
How this fails in real life, concretely
/root/Obsidian/Homelab.md contains this line, written
[20]:
“OpenF1 free tier is gone —
Vault · /root/Obsidian/Homelab.md · written · read on this hostapi.openf1.orgnow returns 401 even on historical data; ad-hoc/no-auth pulls switch to Jolpica (api.jolpi.ca/ergast/f1). Productionpitwall-livebackend is unaffected (authenticates via the OAuth2 token in/root/.hermes/secrets/openf1.env).”
07
Authorization code + PKCE, versus the password grant
Four ways to get a token
RFC 6749 §1.3 defines four grant types [1]. They are worth knowing by name, because which one you are on determines whether your stack self-heals.
- Authorization code (§4.1). The user consents in a browser; the client exchanges a short-lived code. User and client are present. The modern verdict: the default, with PKCE.
- Implicit (§4.2). The token is returned directly in the redirect. User and client present. Deprecated — RFC 9700 §2.1 says clients “SHOULD NOT use” it [4].
- Resource owner password credentials (§4.3). The client POSTs the user's username and password. The user's password is present; the user is not. RFC 9700 §2.4: “MUST NOT be used”.
- Client credentials (§4.4). The client authenticates as itself. No user. For machine-to-machine only.
The handshake that was completed this week: code + PKCE
PKCE is RFC 7636, Proof Key for Code Exchange by OAuth Public Clients (September 2015) [2]. Its abstract names the attack it exists to stop:
“OAuth 2.0 public clients utilizing the Authorization Code Grant are susceptible to the authorization code interception attack. This specification describes the attack as well as a technique to mitigate against the threat through the use of Proof Key for Code Exchange (PKCE, pronounced ‘pixy’).”
RFC 7636 · Proof Key for Code Exchange · Abstract · fetched , rfc-editor.org
The attack: a legitimate app asks the OS/browser to hand back an authorization code; a malicious app that registered the same custom URL scheme also receives it; the attacker then exchanges that code for an access token. The defence is a secret the client invents and keeps:
- The client generates a random
code_verifier— RFC 7636 uses 32 random octets, base64url-encoded. - It sends the hash of that verifier as
code_challenge, withcode_challenge_method=S256. - The authorization server records the challenge and issues an authorization code.
- The client exchanges the code plus the original
code_verifierat the token endpoint. - The server hashes what it receives and compares:
BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge.
The interception is now useless, in RFC 7636's own words:
“An attacker who intercepts the authorization code at (B) is unable to redeem it for an access token, as they are not in possession of the ‘code_verifier’ secret.”
RFC 7636 · §1 · fetched , rfc-editor.org
The point to internalise is that PKCE does not protect the code in transit — it makes the code worthless on its own. The code is no longer a bearer credential; it is a bearer credential that only works in the hands of the process that also holds a one-time secret. This is the general pattern in credential design:
Reduce what a stolen piece of string is worth.
The other half of the handshake is state. In the authorization request the client puts a
random value in state, and the authorization server must echo it back. If the value that
comes back does not match the one you sent, you are looking at a response to someone else's
authorization request and you must refuse it. That is CSRF protection. RFC 9700 §2.1 sets the modern
rule:
“Clients MUST prevent Cross-Site Request Forgery (CSRF). In this context, CSRF refers to requests to the redirection endpoint that do not originate at the authorization server, but at a malicious third party. Clients that have ensured that the authorization server supports Proof Key for Code Exchange (PKCE) MAY rely on the CSRF protection provided by PKCE. In OpenID Connect flows, the nonce parameter provides CSRF protection. Otherwise, one-time use CSRF tokens carried in the state parameter that are securely bound to the user agent MUST be used for CSRF protection.”
RFC 9700 · BCP 240 · §2.1 · fetched , rfc-editor.org
So on a modern server: PKCE is doing double duty (code binding and CSRF), and
state is either the primary CSRF control or a belt-and-braces second one. Either way, the
check that matters at the moment of the redirect is the same one-line assertion: does the
returned state equal the state I generated? When the Open Design login was completed this week,
that was the check performed by hand — and it passed. That is not a formality. It is the difference
between completing your own flow and accepting a code from somebody else's session.
The contrast: the password grant
RFC 6749 §4.3 describes the resource owner password credentials grant: the client asks the user for their username and password directly and POSTs them to the token endpoint. The RFC's own framing is narrow:
“The resource owner password credentials grant type is suitable in cases where the resource owner has a trust relationship with the client, such as the device operating system or a highly privileged application. The authorization server should take special care when enabling this grant type and only allow it when other flows are not viable.”
RFC 6749 · §4.3 · fetched , rfc-editor.org
And a requirement that is frequently ignored:
“The client MUST discard the credentials once an access token has been obtained.”
RFC 6749 · §4.3 · fetched , rfc-editor.org
Fifteen years later, RFC 9700 §2.4 removes all ambiguity:
“The resource owner password credentials grant [RFC6749] MUST NOT be used. This grant type insecurely exposes the credentials of the resource owner to the client. Even if the client is benign, usage of this grant results in an increased attack surface (i.e., credentials can leak in more places than just the authorization server) and in training users to enter their credentials in places other than the authorization server.”
RFC 9700 · BCP 240 · §2.4 · fetched , rfc-editor.org
OpenF1's API is a password-grant API, and their documentation is unusually honest about the consequence [7]:
“The process of obtaining the OAuth2 access token (exchanging your username and password) MUST be implemented in your backend application code. Never embed your direct username and password into client-side applications (like JavaScript running in a user's browser or in a desktop application that can be easily decompiled).”
OpenF1 · auth guide · openf1.org/auth.html · fetched
and, on the token itself:
“Do not embed access tokens directly into your client-side source code if it's publicly accessible. For client applications, the ideal pattern is for your application to communicate with your backend, and your backend then makes authenticated requests to the OpenF1 API.”
OpenF1 · auth guide · openf1.org/auth.html · fetched
That documentation is why the F1 feed is built the way it is. The alternative — page
JavaScript calling OpenF1 with the user's password — would put the credential in a file served to
whoever right-clicks “View Source”, and in localStorage, which OpenF1's own page warns
against by name because of XSS. So the architecture becomes forced: a server-side poller (the pitwall
service) holds the credential in a 600-mode file, mints tokens, polls, and writes a static JSON file;
the browser fetches that file by same-origin GET. The browser never sees a credential because the
credential never needs to go there.
Jargon translationThe trade you have made by choosing the password grant is this: there is nothing to expire interactively, and there is nothing to revoke independently. The password grant has no consent step to re-run — which is why it never needs a human for a token refresh — but it also means the credential is the password, one secret, sitting on a disk, with no scope narrowing and no separate revocation for “this one client”. The F1 poller self-heals for the same reason it is less safe: a machine holds the root secret.
08
Scopes and consent: what a scope actually authorises
The plain explanation
A scope is a text label that describes a capability, sent with the authorization request and echoed back in the token response. RFC 6749 §3.3 defines the mechanics [1]: the scope is a space-delimited, case-sensitive list of strings, the authorization server decides what subset to grant, and if the granted scope differs from what was requested the response must say so.
So far, so simple — and there are three properties of scopes that people consistently get wrong.
1. A scope names a capability, not a policy
files.readonly says “this token can read files”. It does not and cannot say “…but only
the files in this folder, only between 9 and 5, and not if the user revoked you yesterday”. Scopes are a
coarse vocabulary; the caveats live somewhere else (audience restriction, resource indicators,
sender-constrained tokens, or plain old server-side authorisation logic). This is why consent screens
list capabilities. The screen cannot show you caveats, because the protocol has no place to put
them. When a consent screen says “view your files”, that is the entire fidelity of the statement
available.
2. A refresh token inherits the scope of its grant
…and the server is required to keep that binding. RFC 9700 §4.14.2 [4]:
“If refresh tokens are issued, those refresh tokens MUST be bound to the scope and resource servers as consented by the resource owner. This is to prevent privilege escalation by the legitimate client and reduce the impact of refresh token leakage.”
RFC 9700 · BCP 240 · §4.14.2 · fetched , rfc-editor.org
That is why a refresh token is the most dangerous credential in the family. RFC 9700 §4.14.1 says it plainly: refresh tokens “represent the full scope of access granted to a certain client, and they are not further constrained to a specific resource.” An access token is a narrow, temporary, ideally audience-restricted string. A refresh token is the standing ability to mint more of them, at the consented scope, for as long as the grant lives. That asymmetry is the reason §04's human-only failure is so consequential, and the reason a dead refresh token is worth more attention than a dead access token.
3. The consent decision is durable, and mostly invisible afterwards
When a human clicks “Allow”, they create a grant. The token you use tomorrow is not approved tomorrow; it descends from that one click.
Jargon translationThis is why
invalid_grantis a human error andinvalid_tokenis a machine error: the first says the standing permission is gone, the second says this particular string is no good.
One more inherited property, from RFC 6749 §1.5, which is the design intent of the whole system:
“…access tokens may have a shorter lifetime and fewer permissions than authorized by the resource owner.”
RFC 6749 · §1.5 · fetched , rfc-editor.org
Narrower and shorter than the grant. That is the correct direction of travel for every token you ever issue: an access token that is less than the consent, never more.
Consent screens list capabilities rather than caveats
There is a further reason consent UIs read the way they do, and it is worth stating because it is a design constraint rather than a policy: a consent screen must be honest and stable. Honest, because an inaccurate screen is dark-pattern work. Stable, because the screen is rendered by the service asking for access, describing permissions defined by a third party, for a human who has about four seconds of patience. The only thing that can be said accurately and briefly is the capability. So that is what is said, and the caveats live in the API's own enforcement.
There is a security consequence hiding in that, too: the screen is the only moment the human's decision gets made, so “what the screen said” is the boundary of what was consented. A client that later uses a grant for something the screen did not describe is not exercising extra privilege — it is a misrepresentation, and providers treat it as a finding.
092 h
Access tiers that close exactly when you need them
race window 12:00Z–14:00Z
The only band on this page drawn wholly closed — and the only closed band with ink after it. A policy gate is an end with a reopening already scheduled.
The gate
On at 13:03 UTC, during the Singapore Grand Prix (race window 12:00Z–14:00Z), an
unauthenticated call to api.openf1.org returned a 401 whose body was:
“Live F1 session in progress. Global API access (including past sessions) is restricted to authenticated users until the session ends.”
OpenF1 · 401 response body · observed 13:03Z, , during the race, by the weekly audit run (job 9f2b9a4c4998)
Read that as an engineering statement, not a marketing one. It says: during a live session, the unauthenticated tier is closed, including for past sessions; those endpoints reopen when the session ends. This is a policy gate, and it is time-scoped. It is not a rate limit: a rate limit is “you have asked too often” and answers by asking you to slow down. This answers by telling you that the class of request you are making is not available right now, regardless of how slowly you make it.
And here is the strongest available demonstration that the note in the vault was a misread of a
symptom as a cause. This run, at 14:34 UTC — thirty-four minutes after the race window
closed — an unauthenticated request for the same data from the same session returned
HTTP 200 with the full lap payload:
$ curl -s -o laps.json -w "[HTTP %{http_code}]\n" \
"https://api.openf1.org/v1/laps?session_key=11388"
[HTTP 200]
[{"meeting_key":1296,"session_key":11388,"driver_number":3,"lap_number":1,
"date_start":"2026-10-11T12:03:39.077000+00:00", ... }]
Nothing about the account changed. Nothing about the tier changed. A clock moved. That is what a policy gate is, and it is why “cause ≠ symptom” belongs in an engineering lecture and not just a debugging one:
The symptom is a status code, the cause is a state in someone else's system, and the two are only connected by the response body.
Why this matters beyond one API
The general shape is extremely common in tools you will integrate: access that closes when the demand spikes. Free tiers that suspend during peak; batch endpoints that queue during trading hours; data providers whose fresh data is the paid product and whose stale data is free [8]. The engineering consequence is the same in every case, and it is not “buy a bigger tier”. It is: your code must be able to tell “policy” from “failure”, because the correct response is different.
- Policy gate → wait, degrade to a fallback source, or authenticate.
- Broken credential → repair or escalate.
- Rate limit → back off and retry.
- Programming error → fix the request.
Get the classification wrong and you will do the wrong one of those four, sometimes for weeks.
The status codes that decide your code
This table is the decision procedure. Definitions quoted from RFC 9110 §15.5 [5] except 429, which is defined in RFC 6585 §4 [6] (a fact worth knowing, because RFC 9110 does not define it at all). The plain-language companions are on MDN [14].
| Code | The server is saying | Recovery | Do NOT |
|---|---|---|---|
| 400 Bad Request | “your request is malformed” | fix the request; do not retry | retry unchanged |
| 401 Unauthorized | “I don't know you” (RFC 9110: “lacks valid authentication credentials”) | refresh the token; if refresh fails, fall back or escalate | assume the account is gone |
| 402 Payment Required | billing/quota exhausted | fail over to another key/provider, often with a cooldown | retry the same key |
| 403 Forbidden | “I know you, and you're not allowed” | stop; the request is wrong, or the grant lacks the scope | retry — RFC 9110: a client “SHOULD NOT automatically repeat the request with the same credentials” |
| 404 Not Found | “nothing here” | treat as empty; keep going | treat as a credential failure |
| 429 Too Many Requests | “too fast” (RFC 6585: “rate limiting”) | honour Retry-After if present, then retry with backoff |
hammer it |
Three of those deserve a sentence more.
401 is not one thing. RFC 9110 §15.5.2 has a second paragraph that matters enormously for agents: “If the request included authentication credentials, then the 401 response indicates that authorization has been refused for those credentials. The user agent MAY repeat the request with a new or replaced Authorization header field.” That is the licence to refresh the access token once and try again. It is also the exact boundary of the licence: replacing the credential once is expected; a loop that mints a new token on every 401 forever is not.
403 must not be retried. RFC 9110 §15.5.4: the server “understood the request but refuses to fulfill it”, and if credentials were supplied “the server considers them insufficient to grant access… The client SHOULD NOT automatically repeat the request with the same credentials.” A 403 is a decision about permission.
Permission does not improve with repetition.
Retrying a 403 is how a well-meaning poller turns a scope problem into a rate-limit problem, and then into a support ticket.
The body is not optional reading. Two live examples from this run, both from OpenF1, both 401, both completely different:
# bad credentials at the token endpoint
401 {"detail":"Incorrect username or password"}
# a malformed bearer token on a data endpoint
401 {"detail":"Invalid ID token: Invalid base64-encoded string: number of data
characters (5) cannot be 1 more than a multiple of 4"}
The first means “the password in the credential file is wrong” — a config error, five-minute fix. The
second means “the string you sent is not even shaped like a token” — a code path is sending garbage,
possibly an unset variable, possibly the literal text Bearer bogus.token.value from a badly
templated config. A stack that logs only “401” treats both as “the API is being difficult”. A stack that
logs the body fixes each in minutes.
Every external dependency needs a documented plan B. The F1 feed already has one, and
it is recorded in the vault [20]: Jolpica
(api.jolpi.ca/ergast/f1/…) for schedule, standings, and results. Verified live this run —
https://api.jolpi.ca/ergast/f1/current.json returned 200 with
"series":"f1", "season":"2026", and 23 races in the RaceTable
[16]. It is Ergast-compatible, needs no auth, and covers a different
(smaller) surface: schedule and results, not live telemetry. A documented plan B is not a replacement —
it is the thing that makes “the primary is having a moment” a routing decision instead of an
outage.
10
The discipline: read the response, then decide
Pull the thread out of the last nine sections and it is one habit, applied in one order.
- First, classify. Before writing any recovery logic, answer: is this a dead
credential (401 + refreshable?), a dead grant (401 where refresh also fails, or
invalid_grant), a policy state (the gate, the 403), a rate limit (429), a missing resource (404), or a malformed request (400)? The classification comes from the status code plus the response body, never from the status code alone. - Second, check who can mint the next credential. If a process can — refresh token, stored password — automate it, with a margin. If only a person can — an expired grant, a changed password, a lapsed subscription — then no amount of retry logic is the answer; a task for a human is. Encode that decision in the code path itself, e.g. refresh once; if the refresh fails, stop retrying and raise something a human will see.
- Third, probe before escalating.
AGENT_AUTH_REQUIREDmay mean “dead grant, needs a human” or “token lapsed mid-spawn, retry will work”. A real call as the real user distinguishes them in seconds. Escalating too fast burns human attention; escalating too slowly burns weeks. - Fourth, alert on the countdown, not the failure. “Expires in 6 days” is a schedulable fact. “Failed” is a postmortem. The only credential failures you can prevent are the ones you saw coming.
- Fifth, write down the plan B before you need it. Jolpica existed in the vault before the gate closed, which is why the 401 during a live session was a routing question and not an incident.
- Sixth, keep the human-only list short, and keep it where a human reads. The list will never be empty — there is always a purchase, a consent, or a password. Its length is a design choice: every password grant you replace with a code+PKCE flow moves one entry from the human column into the machine column. But the ones that remain must be named, dated, and visible.
11
Key takeaway
Every credential in your stack has an expiry, and the only question that matters
about it is who can mint the next one when it lapses. If a machine holds the root
secret, the failure self-heals: refresh early with a margin (the pitwall's 50 minutes against a
60-minute token), serialise refreshes so single-use refresh tokens never race, and store a deadline you
can print. If only a person can mint it — an expired refresh token, a changed password, a lapsed
grant — then no retry, backoff, or cron will ever recover it, and the correct engineering is a
countdown, a dated entry, and a task a human will see. The oduser grant died on
and the Google Drive grant has been dead since ; neither was a mystery, and neither was
recoverable by a machine. Everything else in this unit — PKCE, state, scopes, consent —
exists to make that one moment, when a human decides, worth as much as possible.
12
Go deeper
Every link below was fetched and returned a real page this run ( UTC); the HTTP status observed is noted where it was not 200. The numbering is the same run used by the keys in the body — every key resolves down to an entry here, and every entry names the sections that cite it.
Primary specifications-
[1]
RFC 6749 — The OAuth 2.0 Authorization Framework
https://www.rfc-editor.org/rfc/rfc6749.txt
The source for §1.4 (access token), §1.5 (refresh token), §1.3 (grant types),
§4.1 (authorization code), §4.3 (password grant), §5.1 (
expires_in,Cache-Control: no-store), §10.4 (refresh token rotation), §10.5 (authorization codes must be short-lived and single-use). Cited in §01 · §03 · §05 · §06 · §07 · §08 - [2] RFC 7636 — Proof Key for Code Exchange (PKCE) https://www.rfc-editor.org/rfc/rfc7636.txt Read §4 (the protocol), §7.1 (verifier entropy) and Appendix B (a worked S256 example). Cited in §07
-
[3]
RFC 6750 — Bearer Token Usage
https://datatracker.ietf.org/doc/html/rfc6750
How the
Authorization: Bearerheader, theWWW-Authenticatechallenge, and theinvalid_token/insufficient_scopeerror codes are defined. Cited in §01 - [4] RFC 9700 — Best Current Practice for OAuth 2.0 Security (BCP 240) https://www.rfc-editor.org/rfc/rfc9700.txt The document that updates 6749/6750. §2.1 (implicit grant deprecation, CSRF and PKCE), §2.4 (password grant MUST NOT be used), §4.14 (refresh token protection and rotation). Cited in §04 · §05 · §07 · §08
-
[5]
RFC 9110 — HTTP Semantics
https://www.rfc-editor.org/rfc/rfc9110.txt
§15.5 for 400/401/403/404, §6.6.1 for the
Dateheader and clock-skew fallbacks. Cited in §01 · §05 · §06 · §09 - [6] RFC 6585 — Additional HTTP Status Codes https://www.rfc-editor.org/rfc/rfc6585.txt §4 defines 429 Too Many Requests, which HTTP Semantics does not. Cited in §09
-
[7]
OpenF1 auth guide
https://openf1.org/auth.html
The password-grant exchange,
expires_in: 3600, MQTT/WebSocket streaming with the token as the password, and the “MUST be implemented in your backend application code” guidance. Cited in §02 · §07 - [8] OpenF1 API docs https://openf1.org/docs/ Endpoints and attributes; the free/paid split (“Historical data (from 2023 onwards) is free and accessible without authentication. Real-time data requires a paid subscription”). Cited in §08 · §09
- [9] Hermes — Credential Pools https://hermes-agent.nousresearch.com/docs/user-guide/features/credential-pools A worked 401/402/429 decision procedure, refresh-token rotation across processes, and the file-lock adoption pattern quoted in §05. Cited in §05 · §06 · §09
- [10] Hermes — Secrets https://hermes-agent.nousresearch.com/docs/user-guide/secrets Where credentials come from, what a child process does and does not inherit. Cited in §06
-
[11]
Hermes — Passwords & Logins, and the egress credential-injection proxy (iron-proxy)
https://hermes-agent.nousresearch.com/docs/user-guide/features/credential-vault
https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy The strongest form of the answer to §01: keep the credential out of the agent's environment entirely and inject it at egress. Cited in §01 -
[12]
Google — OAuth 2.0 overview
https://developers.google.com/identity/protocols/oauth2
Useful for the vocabulary around
invalid_grantand consent revocation; the web-server page (/oauth2/web-server) documents the refresh-token exchange. Cited in §04 -
[13]
Claude Code docs
https://docs.claude.com/en/docs/claude-code/overview
The tool whose
/loginflow this lecture's case B turns on. Cited in §04
- [14] MDN — HTTP status and header reference 401 Unauthorized · 403 Forbidden · 429 Too Many Requests · WWW-Authenticate · Retry-After · Cache-Control Verified reachable, not a primary source — each page fetched this run Cited in §09
- [15] Wikipedia — “OAuth” https://en.wikipedia.org/wiki/OAuth Verified reachable, not a primary source Cited in §02
-
[16]
Jolpica — the documented F1 plan B
https://api.jolpi.ca/ergast/f1/current.json
Returned
200this run with"series":"f1","season":"2026"and 23 races in theRaceTable. Ergast-compatible, no auth, schedule and results only — not live telemetry. Cited in §06 · §09
-
[17]
The self-healing case, in full
/opt/pitwall/live_feed.py — lines 16–65, 236–275
12,238 B, mtime .
POLL_S = 10on line 27,TOKEN_TTL = 50 * 60on line 28,CRED_FILEon line 16,OUTon line 17 — the lecture's quoted block matches the file. Cited in §03 -
[18]
The credential itself
/root/.hermes/secrets/openf1.env
stat -c '%a %U %s'→600 root 75: mode 600, root-owned, 75 bytes. Cited in §01 · §03 -
[19]
The
AGENT_AUTH_REQUIREDprobe, and why a presence check is not a validity check /root/.hermes/skills/workflow/open-design-bridge/references/od-run-failure-diagnostics.md Cited in §04 · §06 - [20] The gate misread, and the Jolpica fallback entry /root/Obsidian/Homelab.md Cited in §04 · §06 · §09
- [21] The credential-handling rules for the F1 feed /root/.hermes/skills/research/f1-data/SKILL.md Cited in §03
Lecture 301-08 · KC 301 · Hard Engineering · written UTC. Every quotation and
HTTP observation above was read from a live fetch or from this host on that date: RFC
6749/7636/6750/9110/9700 and 6585 fetched from rfc-editor.org and datatracker.ietf.org; OpenF1
/token returned 401 {"detail":"Incorrect username or password"} and
www-authenticate: Bearer on a deliberately bad credential; a deliberately malformed bearer
token returned 401 with the base64 error quoted in §09; an unauthenticated
GET /v1/laps?session_key=11388 returned 200 with data at 14:34:42Z, after the
12:00Z–14:00Z race window; /var/www/dllama.tech/f1/live.json was in standby at
14:34Z. The live-session gate text quoted in §09 was observed at 13:03Z the same day, during the race,
by the weekly audit run (job 9f2b9a4c4998). Anything not verifiable from this host or a
fetched document is marked as attributed in place.