Skip to content

Sweep expired authorization sessions so abandoned PKCE verifiers do not persist - #1569

Open
GeiserX wants to merge 4 commits into
UsefulSoftwareCo:mainfrom
GeiserX:fix/pkce-verifier-at-rest
Open

Sweep expired authorization sessions so abandoned PKCE verifiers do not persist#1569
GeiserX wants to merge 4 commits into
UsefulSoftwareCo:mainfrom
GeiserX:fix/pkce-verifier-at-rest

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 12, 2026

Copy link
Copy Markdown

TL;DR

An OAuth authorization session stores its PKCE verifier so the callback can redeem the code. Expired sessions were only discarded lazily, on completion — but an abandoned flow is never completed, so that check never ran for it and nothing else swept the table.

The verifier sat in the database in plaintext, indefinitely, for every flow a user started and walked away from.


What happens today

complete checks whether the session has expired and discards it if so. That is the only cleanup in the table. It runs exactly once per flow, and only for flows that come back.

Users abandon OAuth flows constantly — they close the popup, deny consent, get distracted at the provider's login page. None of those sessions is ever completed, so none is ever discarded. The table grows without bound and each row holds a live secret.

What this changes

Starting a new authorization sweeps sessions that have already expired.

Doing it on start rather than on a timer is deliberate:

  • It bounds the table by how often authorization is begun, rather than by how often it is abandoned — the two are correlated, and the first is the one we can observe.
  • It needs no scheduler, so it works identically in every host, including the stateless multi-isolate ones where a background job has nowhere to live.
  • It runs on a path that is already writing, so it costs one delete rather than a new round trip.

It is owner-scoped by the table's own delete policy, so a caller only ever sweeps rows it can already see, and best-effort — failing to tidy up must never stop someone connecting an account.

Separately: a session whose completion cannot be retried is now dropped rather than left behind. Previously a flow that failed in a way the user would have to restart still left its verifier in place.

What this does not claim

The sweep bounds how long a verifier persists; it does not stop one being stored. A verifier is only useful together with its matching authorization code, which is single-use and short-lived, so the residual value of a swept-late verifier is low. The point is that "low value" and "kept forever" is still a worse position than "low value" and "kept until it expires".

Tests

Two tests: the sweep fires on start and removes only expired rows, and a non-retryable completion deletes its session. Both mutation-checked.

Scope

Independent of #1564 — it touches only oauth-service.ts.

An in-flight flow parks its PKCE verifier in oauth_session in plaintext, which
is fine while the flow can still spend it. The happy path and cancel delete the
row, and an expired redemption drops it lazily — but a completion that FAILED
did not, and nothing sweeps the table. A flow that died there kept its verifier
indefinitely, and for an abandoned flow the lazy path never runs.

restartRequired is the authorization the code already computes for this: false
means the caller may redeem the same state again, so deleting then would turn a
retryable hiccup into a forced restart. Only the unredeemable case is cleaned
up, best-effort, so a failed cleanup cannot replace the real error.
The helper's any-typed parameters widened the Effect error and context channels
to unknown, so the suite passed while typecheck failed. Inlining the flow lets
the real types flow through.
An abandoned flow is never completed, so the lazy expiry check in complete never
runs for it, and nothing else sweeps the table — its PKCE verifier sat there in
plaintext indefinitely. Closing that was the larger half of the earlier session
cleanup and had been left open for needing host lifecycle work.

It does not: sweeping on start costs one delete on a path that is already
writing, needs no scheduler in any host, and bounds the table by how often
authorization is STARTED rather than by how often it is abandoned. The delete is
owner-scoped by the table's own policy, so a caller only ever sweeps rows it can
already see, and it is best-effort so tidying up cannot stop someone connecting
an account.
@GeiserX

GeiserX commented Aug 13, 2026

Copy link
Copy Markdown
Author

Context for this one: #1585 explains why this PR and twelve others exist — they came out of a single pass over credential handling, asking for each credential where it ends up, how long it stays, and who can read it once it's there.

This PR stands alone and doesn't depend on any of the others.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant