ADR-082 — The table carries the dates, the tokens carry the truth
Status: accepted Date: 2026-09-27 Continues ADR-079, which closed on the gap it had left open: a consent screen with no way back. Related ADR-081 §6, on what bounds an agent that has been let in.
Context
ADR-079 built a door an agent walks through once. Nothing showed the user what was behind it
afterwards, and nothing took the access back. The only revocation in the codebase was account
deletion, and CredentialsRevokerInterface::revokeCredentialsForClient() — the method the
ticket prescribed — was called nowhere.
That was tenable while the door was being built. It stopped being tenable as a resting state,
because the scopes are account-wide: trips:read reaches every trip of the user, there is
no per-trip consent and none is planned (ADR-079, restated in ADR-081 §6). The user's only
lever is which application holds access — so they have to be able to see the list, and to
shorten it.
Two facts about the existing schema shaped everything below.
The token tables cannot say when. oauth2_access_token carries
identifier, expiry, user_identifier, scopes, revoked, client; oauth2_refresh_token carries
identifier, expiry, revoked, access_token. One temporal column each, and it points forward.
Access tokens live fifteen minutes and refresh tokens rotate, so a single authorization leaves
dozens of rows behind, none of which knows the day the user said yes.
The user identifier in those tables is an email address. It is what the token carries and what the provider loads by, which makes an email change a silent mass revocation (§5).
Decision
1. Two records, one invariant, stated as a rule
A durable oauth_grant row records what the tokens cannot: authorized_at, last_used_at,
and the scopes as granted. The tokens keep deciding what they already decide: whether the
application can still act.
The rule that binds them, and the title of this ADR:
The table carries the dates, the tokens carry the truth.
Concretely, the account screen lists a grant only while a live, unrevoked token still backs it. A row on its own proves nothing; it never did. Nothing sweeps the table, so after a month of inactivity a row would otherwise claim an access that expired weeks ago.
This is the invariant between the two records, not a join to be optimised away later. Any future mechanism that revokes tokens without knowing about grants leaves the screen lying, and that is the failure mode to look for first when it does.
The filter includes refresh tokens, not only access tokens. An access token lives fifteen minutes; a filter that looked at them alone would make every application vanish a quarter of an hour after its last call and reappear on the next refresh.
2. The grant is keyed on the user entity, the tokens on the address
oauth_grant.user_id is a foreign key to user, where the bundle's tables carry
user_identifier, an email address. An address changes; a person does not. That difference is
what lets a grant survive an email change — the user still sees their applications and can
re-authorize them — and it is also why the revocation that must accompany one has to be written
by hand (§5).
3. The row is written at token issuance, and nowhere else
The obvious seat is a trap and two plausible ones are dead:
AccessTokenManagerInterface::save()is also the revocation hook (AccessTokenRepository:77and:90), and a refresh calls both in sequence (RefreshTokenGrant:80then:86);ACCESS_TOKEN_EXTRA_CLAIMS_RESOLVEis dead here: our ownAudienceBoundAccessTokenRepository::getNewToken()replaces the method that dispatches it (ADR-079), so a listener would never run;TOKEN_REQUEST_RESOLVEcarries neither user nor client.
The write therefore sits in AudienceBoundAccessTokenRepository::persistNewAccessToken(), a
method we already own, called once per token issued and never on revocation.
Three properties of that write are load-bearing:
- it is a DBAL upsert, never
persist()/flush(). The token is already flushed at that point, so a unique-constraint violation would close the EntityManager and answer 500 to an agent whose token is live in the database; - the conflict resolves to
DO UPDATE SET scopes, notDO NOTHING. A second authorization that widens the scopes (trips:read→+ trips:write) would otherwise leave the original row untouched and the screen would under-declare what the application may do — the worst direction for a screen whose purpose is informed revocation.authorized_atis kept: that is the day the user opened the door, and showing a later one would hide it; - a user it cannot resolve is logged, not raised. Everything on this path is paid for in failed token issuance, i.e. agents that stop connecting.
4. Revocation is a tombstone, and it intersects user and client
revoked_at is stamped; the row is not deleted. RefreshTokenGrant checks revocation (:128),
revokes the old token (:80-83), then issues (:86). A revocation committing inside that
window lets the refresh complete, and §3's write would recreate a deleted row with a brand-new
authorized_at — the application the user just cut off, listed as freshly authorized. A
partial unique index ((user_id, client_identifier) WHERE revoked_at IS NULL) leaves room
for a genuine re-authorization beside the tombstone; a total unique index would force a choice
between losing the history and refusing the re-authorization.
The token side is written by hand in GrantRevoker, and not with
revokeCredentialsForClient(). That method filters on the client alone
(DoctrineCredentialsRevoker:80-131): called from one user's account page it would cut the
application off for every user of it. The intersection covers access tokens, the refresh
tokens hanging off them — oauth2_refresh_token has no client column, so the client goes in the
subquery — and authorization codes. A test with two users sharing a client asserts the
difference, rather than a comment asking future readers to be careful.
Account deletion is the exception: it deletes the rows. The tombstone defends a refresh race inside a live account; it has nothing to defend after anonymisation, and a row linking an anonymised account to third-party applications has no reason to survive.
5. Changing an email address revokes, explicitly
VerifyEmailChangeProcessor set the new address and flushed. The tokens carry the old address
as their user identifier and the provider loads by it, so every live agent token answered 401
afterwards with nothing to explain it — a silent mass revocation, one file away from the
comment in AccountDeleteProcessor that warns about exactly this ordering.
The revocation is now explicit and ordered before the change. This is not a consequence of the feature; the feature made it visible.
6. What counts as use
last_used_at is written by McpGrantUsage, a third handler decorator placed inside
McpCallBudget, so only a call that was allowed and paid for counts. One decorator, one
subject: the budget's contract is what a call may cost, and a class that also wrote an
unrelated column would be one nobody could reason about later. McpHandlerChainTest pins the
full order — scope, budget, usage, tools — with the reason for each position.
A tool call or a resource read is use. initialize and tools/list are not: an agent that
connects, lists and leaves reads as never used. The screen answers "what has this application
done with the access", not "has it ever held a connection".
Three constraints on the write, all about not harming the call:
- it runs after the inner handler. At that depth the SDK owns the HTTP response, so a failure here could not be reported anyway, and a write before the call could poison the transaction the tool opens;
- it lets no exception out. The call succeeded; a missing timestamp is a wrong line on a screen, not a failed edit;
- it never reads the database to decide whether to write. A handshake-era batch carries up
to a hundred messages (ADR-081 §1), and a SELECT per message would trade one write for a
hundred reads. A cache key per grant, five minutes, decides instead — in a pool that stays on
Redis under test, like
cache.oauth_consent, or the test that it does not write twice inside the window would prove nothing.
Granularity follows: "last used" is as durable as that cache, so the screens say 20 Sept., never a time of day.
7. The screens show the host, because the name is the attacker's
oauth2_client.name comes from the client's own metadata document, is written once at first
authorization and never refreshed (ClientIdMetadataDocumentListener:72-77). A screen showing
only that name is a phishing surface: "Bike Trip Planner Officiel" looks like us, on the page
where a user decides whether to cut an access.
The host of the client_id is the only part of a client's identity it cannot choose. Both
are shown, on their own lines, on web and on mobile — and the name is never interpolated into
a sentence, including in the confirmation dialog, so it cannot borrow the authority of our
own wording. This is the rule ADR-081 §4 states for error messages, applied to a screen.
The item operations are addressed by the grant's uuid, never the client's URL, which has no business in a path or in an access log.
8. Revoking asks for confirmation, not for a typed word
Account deletion makes the user type SUPPRIMER because it cannot be undone. Revoking can: the
user re-authorizes from the agent. A plain confirmation on both platforms — DestructiveDialog
without a keyword on the web, Alert.alert on mobile — and a mistake in the safe direction
costs one re-authorization.
Consequences
- A user can see and cut every application that holds access to their account, from either client, without deleting the account.
- The account screen stops being a place where the only exit is the nuclear one.
- One more table takes part in the OAuth flow, and one more write sits on the token-issuance path. §3 is why that write cannot fail the issuance.
- The rule in §1 is now something a future change has to honour or explicitly break.
Known limits, assumed
- The two records can still disagree, and the filter is the only thing holding them
together. A mechanism that revokes tokens without stamping
revoked_atleaves a row that the filter will hide rather than correct — right on the screen, wrong in the table. That is the intended failure direction, not an invariant. - A refresh token orphaned by the bundle's expiry sweep is unreachable from any revocation.
clearExpired()setsrefresh_token.access_tokento NULL (ON DELETE SET NULL) before deleting expired access tokens, so a refresh token with weeks of life left stops being reachable through the subquery every revocation uses — including account deletion today — and invisible to §1's filter. Nothing schedules that sweep: our ownPurgeExpiredTokensCommandonly touches PWA session tokens and magic links. This becomes blocking the day someone schedulesleague:oauth2-server:clear-expired-tokens, and it is a fix to make before scheduling it, not after. last_used_atis throttled and therefore approximate, by five minutes, and is lost if the Redis pool is flushed between the call and the write. §6.- There is no notification when an application is authorized. The consent screen is the only moment the user is told, and this screen is the only place they can check afterwards.
- Nothing expires a grant for inactivity. The row survives; the tokens do not, and §1's filter is what makes that invisible.
- The mobile screen has no maquette. It follows the components and spacing of its neighbours, which is a convention and not a design decision.