dllama.tech · Knowledge Course KC 301-08
KC 301 · Hard Engineering — Unit 08

Credential lifecycle: which expiries self-heal, and which need a human

~35 min read · Written · Research: Mara · Slug credential-lifecycle
Summary

A credential is not one thing with one expiry date. It is usually a short-lived access token plus a longer-lived refresh token, and those two expiries fail in completely different ways. When the access token lapses, a machine with the right long-lived secret can mint a new one — the failure self-heals, silently, and nobody notices. When the refresh token lapses, no retry, no backoff, and no cron can help: a person has to open a page, consent, and paste a code back. This lecture is about how to tell those two cases apart before they bite you, using two real cases from this week: the pitwall poller on OpenF1, which refreshed its token at 50 minutes against a 60-minute expiry and never failed; and Claude Code on the Open Design build user, whose refresh token expired on and stayed dead for weeks because only a human could revive it. Along the way: the difference between an authorization code + PKCE handshake and a password grant, what a scope actually authorises, and why a 401 during a live Formula 1 session meant “come back later”, not “the free tier is gone”.

Audience

Someone who runs an agent stack daily and wants the layer under it.

KC 301 · Hard Engineering · Unit 08 of 12 · published
Access TTL 3600 s OpenF1 token response: {"expires_in":"3600"} — the value the server states
Re-mint at 3000 s grep -n TOKEN_TTL /opt/pitwall/live_feed.py → 28: TOKEN_TTL = 50 * 60
Margin 600 s arithmetic: 3600 − 3000. RFC 9700's standard margin is 60 s (§05)
Credential file 600 stat -c '%a %U %s' /root/.hermes/secrets/openf1.env → 600 root 75

The poller is /opt/pitwall/live_feed.py, run as the pitwall-live systemd service; it writes /var/www/dllama.tech/f1/live.json, a plain static file the browser fetches by same-origin GET. The credential is read by that server-side process and the browser never touches it.  ·  Two grants on this box are closed and only a person can reopen them: the oduser Claude refresh token expired (its access token followed at 22:40 UTC), and the Google Drive grant has been dead since . Measured to this page's own stamp, , those are 28 days and 49 days.  ·  Re-measure rule: this page ages. The two spans above are arithmetic on two printed dates and this page's stamp — a later reader should re-run the dates rather than trust the span.  ·  All values verified on this host ; live_feed.py 12,238 B, mtime .  ·  600 appears twice above and means two different things: ten minutes of deliberate slack in the poller, and the file mode on the credential. Both are decisions.

Fig. 1 The ladder and the margin
A · The ladder — seven decades, one scale
Window
Lifetime
10110 s1022 min10317 min1042.8 h1051.2 d10611.6 d107115.7 d1083.2 y
Every window that heals itself is between a minute and two hours Every window that needed a person is four weeks or older
openf1 access_token
§02 · expires_in
3600 s

This is the page's only moving part. It started when you loaded the page and it empties in 60 minutes — the life of an OpenF1 access token. Reload and it restarts. It measures your browser, not a token: nothing here is connected to anything. The first two notches are the two margins — theirs 60 s, ours 600 s — exactly one decade apart, which on this axis is exactly one rung. The two marks at the right-hand end are 1.13 % of this axis apart; panel B is that gap, zoomed.

Motion is off here, so the bar is pinned at its midpoint — half the hour gone, half left. It only reads honestly while it drains; a full bar says a token with its whole life ahead of it, which is the one state this figure never means.

rfc 9700 margin · 60 s our margin · 600 s re-mint · 3000 s token end · 3600 s → re-minted
the gate, closed
§09 · race window 12:00Z–14:00Z
2 h

Closed by a clock, on purpose, and reopened by the same clock: at 14:34:42Z the same unauthenticated request returned 200.

oduser grant
§04 · refresh token expired
28 d ∞
google drive grant
§04 · dead since
49 d ∞

Both tails run past the last rung because nothing on the axis ends them. Only a person does.

No window in this stack lives between 2.8 hours and 11.6 days — 36 % of the ladder, empty.

Measured: the gap between the last self-healing window (2 h, 40.82 %) and the first human-only one (28 d, 76.91 %) is 36.09 % of the band; the two decade rungs the line above names, 104 and 106, are 28.57 % apart. An honest axis shows what is absent as well as what is present.

B · The margin — one hour, one scale
Window
Of the hour
openf1 access_token
linear 0 → 3600 s
3600 s
  The margin · 600 s · 16.67 % of the hour
rfc 9700 standard margin
§05 · 60 s, for comparison
60 s

The same hour, the same scale: 1.67 % of it. Two notches on this panel — ours at 83.33 %, theirs at 1.67 %.

Open — the credential works Re-minted — something mints the next one Closed — no retry reaches here (∞) Not stated — the lecture gives no duration (—)
The difference between a token that races and a token that has never failed is 1.13 % of the ladder and 16.67 % of the zoom. The scale decides what you can see, and the load-bearing number here is the small one. Panel B is the honest counterweight to panel A: without it a seven-decade axis flatters itself and the margin is a hairline nobody can read — which is §09's lesson in another form, that one view is not enough to tell a symptom from a cause.
Sources & drawing parameters — Panel A: logarithmic, domain 101 s … 108 s (seven decades), position(s) = (log10(s) − 1) / 7. The axis's origin, 10 s, is not arbitrary: it is POLL_S = 10 in /opt/pitwall/live_feed.py, the interval the poller runs at. Rows, left to right: 60 s 11.12 % · 600 s 25.40 % · 3000 s 35.39 % · 3600 s 36.52 % · 7200 s 40.82 % · 28 d 76.91 % · 49 d 80.38 %. Decade rungs at 0.00 / 14.29 / 28.57 / 42.86 / 57.14 / 71.43 / 85.71 / 100.00 %. Panel B: linear, domain 0 → 3600 s, position(s) = s / 3600; 60 s 1.67 % · 600 s 16.67 % · 3000 s 83.33 % · 3600 s 100.00 %. One row per duration the lecture states and no others; if a row is added or removed, this line changes with it. Every value is emitted from the two formulas above — none is typed beside a duration by hand.
01

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 translation

A 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:

The bearer header, as every OpenF1 endpoint expects it
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

POST /token with a deliberately bad credential · observed
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

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:

The seven steps, and the one that decides everything
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:

Table 1 · Two kinds of credential, and the third case this lecture exists for
ShapeWhat expiresWho can mint the next oneFailure 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

03600 s

Case A: the expiry that self-heals — pitwall and OpenF1

On the ladder · Fig. 1 panel A, same scale
openf1 access_token
re-mint, then end, then the next one
3600 s

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:

/opt/pitwall/live_feed.py · lines 16–28, verified this run
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:

load_creds() and get_token()
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:

The entire credential lifecycle, in one 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:

  1. Network latency on the mint itself. expires_in: 3600 is 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.
  2. 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.)
  3. 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

api_get_opt() · 404 is data, 401 is not
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 is 401 {"detail":"Incorrect username or password"} with www-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

0449 d

Case B: the expiry that needs a human — Claude Code on oduser

On the ladder · Fig. 1 panel A, same scale
oduser grant
refresh token expired
28 d ∞
google drive grant
dead since
49 d ∞

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]:

Run diagnostics · the failure shape
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:

The only thing that repairs it
# 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:

  1. The poller tries to use the access token. It is dead. 401.
  2. 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].
  3. 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:

The probe that turns an ambiguous error into a decision
# 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

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 auth.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.”

Hermes · Credential Pools · user guide · fetched

Three design rules are visible in that paragraph, and they generalise beyond Hermes:

  1. Serialize the refresh. One writer at a time. Otherwise single-use tokens turn your own concurrency into a breach signal.
  2. 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.
  3. 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:

  1. 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 stored expires_in and a .now() that may have moved.
  2. 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.
  3. 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

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.

  1. 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 holds token, expires_at, issued_at, and probed_at can answer it instantly, offline, and can print it in a status line.
  2. 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.
  3. 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:true while 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.
  4. Log the body, not just the code. Compare these two log lines.
The same failure, logged two ways
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.

  1. 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.
  2. Never let the token sit in a cache. RFC 6749 §5.1 [1] requires Cache-Control: no-store on 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 a curl -d "password=…" on a command line), or a browser's localStorage is 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 — api.openf1.org now returns 401 even on historical data; ad-hoc/no-auth pulls switch to Jolpica (api.jolpi.ca/ergast/f1). Production pitwall-live backend is unaffected (authenticates via the OAuth2 token in /root/.hermes/secrets/openf1.env).”

Vault · /root/Obsidian/Homelab.md · written · read on this host
07

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:

  1. The client generates a random code_verifier — RFC 7636 uses 32 random octets, base64url-encoded.
  2. It sends the hash of that verifier as code_challenge, with code_challenge_method=S256.
  3. The authorization server records the challenge and issues an authorization code.
  4. The client exchanges the code plus the original code_verifier at the token endpoint.
  5. 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 translation

The 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

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 translation

This is why invalid_grant is a human error and invalid_token is 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

092 h

Access tiers that close exactly when you need them

On the ladder · Fig. 1 panel A, same scale
the gate, closed
race window 12:00Z–14:00Z
2 h

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:

The same request, after the window · observed 14:34:42Z,
$ 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].

Table 2 · Six status codes and what each one licenses you to do
CodeThe server is sayingRecoveryDo 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:

Two 401s, two different fixes · observed
# 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

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.

  1. 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.
  2. 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.
  3. Third, probe before escalating. AGENT_AUTH_REQUIRED may 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.
  4. 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.
  5. 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.
  6. 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

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

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. [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. [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. [3] RFC 6750 — Bearer Token Usage https://datatracker.ietf.org/doc/html/rfc6750 How the Authorization: Bearer header, the WWW-Authenticate challenge, and the invalid_token / insufficient_scope error codes are defined. Cited in §01
  4. [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. [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 Date header and clock-skew fallbacks. Cited in §01 · §05 · §06 · §09
  6. [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
Vendor and tool documentation
  1. [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
  2. [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
  3. [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
  4. [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
  5. [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
  6. [12] Google — OAuth 2.0 overview https://developers.google.com/identity/protocols/oauth2 Useful for the vocabulary around invalid_grant and consent revocation; the web-server page (/oauth2/web-server) documents the refresh-token exchange. Cited in §04
  7. [13] Claude Code docs https://docs.claude.com/en/docs/claude-code/overview The tool whose /login flow this lecture's case B turns on. Cited in §04
Plain-language companions
  1. [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
  2. [15] Wikipedia — “OAuth” https://en.wikipedia.org/wiki/OAuth Verified reachable, not a primary source Cited in §02
  3. [16] Jolpica — the documented F1 plan B https://api.jolpi.ca/ergast/f1/current.json Returned 200 this run with "series":"f1", "season":"2026" and 23 races in the RaceTable. Ergast-compatible, no auth, schedule and results only — not live telemetry. Cited in §06 · §09
On this box
  1. [17] The self-healing case, in full /opt/pitwall/live_feed.py — lines 16–65, 236–275 12,238 B, mtime . POLL_S = 10 on line 27, TOKEN_TTL = 50 * 60 on line 28, CRED_FILE on line 16, OUT on line 17 — the lecture's quoted block matches the file. Cited in §03
  2. [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
  3. [19] The AGENT_AUTH_REQUIRED probe, 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
  4. [20] The gate misread, and the Jolpica fallback entry /root/Obsidian/Homelab.md Cited in §04 · §06 · §09
  5. [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.