The hard part of "connect your Gmail" is the refresh token
Vault
Every agent platform ships "Connect your Google account" in its first month. The authorization URL, the callback, the token exchange: an afternoon with the provider's docs. The part that takes the rest of the year is what happens after — keeping that grant usable, for every user, across every provider, while the agent that depends on it runs unattended.
Seekrit Vault moves the whole OAuth lifecycle into the executor, a Worker in your own Cloudflare account that is the only thing holding the decryption key. Your backend never sees an authorization code, an access token, or a refresh token. This post is about what the executor does with them, because the details are where the bugs live.
import { defineVaultConfig, oauth2Preset } from "@seekrit/vault-executor";
export default defineVaultConfig({
providers: [
oauth2Preset.google({
id: "google-gmail",
consentSummary: "read and send email as you",
clientId: "123.apps.googleusercontent.com",
clientSecretBinding: "GOOGLE_CLIENT_SECRET", // a Wrangler secret's *name*
scopes: ["https://www.googleapis.com/auth/gmail.modify"],
}),
],
});
That is the whole configuration for Gmail. Presets exist for Google, Microsoft,
and GitHub App user tokens; oauth2Provider({ … }) takes any
authorization-code provider's endpoints explicitly.
Why does the exchange happen on the executor?
Because whoever receives the redirect receives the tokens. The redirect URI
registered with the provider is https://<your executor>/oauth/callback, so
the authorization code lands in your Cloudflare account. The executor checks
the state against a cookie it set on its own origin when the flow began,
exchanges the code using the client secret it reads from its Worker
environment, asks the provider who signed in (that becomes the connection's
label), encrypts the tokens to its own key, and files the ciphertext with
seekrit.
Two things follow. Seekrit's API is never in the OAuth round trip — not the code, not the PKCE verifier, not the tokens. And each platform brings its own OAuth client. If seekrit owned the client, the code would land with seekrit, and the claim that seekrit cannot read your users' credentials would be false. It also happens to be what Google requires for restricted Gmail scopes: a verified, platform-owned app.
Your client secret is read by name from a Wrangler secret. It is never in
vault.config.ts, so it is never in your repository, and never in the display
catalog seekrit stores.
What goes wrong with refresh?
An access token lasts an hour. The agent needs the account for a year. The refresh token bridges the gap, and three things go wrong with it in practice.
Two requests refresh at once. The agent fans out, two tool calls arrive with the same expiring token, both refresh, and depending on the provider one of the new tokens is now invalid. The executor runs refresh inside a per- connection Durable Object: a single writer, so concurrent requests share one refresh and both proceed with the result.
The refresh token is single-use. Google's refresh tokens survive use —
the preset calls that strategy stable. GitHub App user tokens do not: a
refresh spends the refresh token and issues a new one, which the preset
calls rotating. With a rotating token, a crash between "the provider
accepted the old refresh token" and "we saved the new one" loses the
credential entirely — and a naive retry presents a token the provider has
already invalidated, which it may treat as a replay and revoke the grant.
The executor handles this with a journal. Before calling the token
endpoint for a rotating provider, the connection's Durable Object writes
dispatching, revision n to its storage. On success it writes the new
revision and clears the journal. If the Worker dies or the provider times out,
the next use finds a journal with no recorded outcome. The executor does
not retry with the old token. It marks the connection reauth_required
with reason refresh_outcome_unknown, and a reconnect link repairs it. Losing
one grant to a reconnect is the correct cost; presenting a spent token is not.
The provider says no. A 401 on a token that was not just minted gets
one forced refresh and a retry. A 401 on a fresh token means the grant
itself is gone — the user revoked it in their Google account, or the password
changed — and the connection is marked reauth_required with
provider_rejected_credential. Nothing in the dashboard can flip it back to
active, on purpose: only a reconnect, where the user authorizes again, does.
What does your code see?
A response. vault.fetch sends the executor a request with no credential in
it; the executor injects Authorization: Bearer …, refreshes first if the
token is about to expire, forwards, and returns the provider's response with
any echo of the token redacted. When the executor refuses — a host outside
the provider's rules, a connection in reauth_required, a userRef that
does not own the connection — it throws a VaultExecutorError with a code,
so a refusal is never mistaken for a provider error.
import { Vault, VaultExecutorError } from "@seekrit/sdk/vault";
const vault = new Vault();
try {
const res = await vault.fetch({
userRef: "u_123",
connectionId,
request: {
method: "POST",
url: "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
headers: { "content-type": "application/json" },
body: JSON.stringify({ raw }),
},
});
} catch (err) {
if (err instanceof VaultExecutorError && err.code === "connection_reauth_required") {
const link = await vault.connect.create({
userRef: "u_123",
providerId: "google-gmail",
purpose: "reconnect",
connectionId,
});
// text `link.url` to the user
}
}
The Google preset derives the allow rules from the scopes you request —
gmail.* scopes permit gmail.googleapis.com, a calendar scope permits the
calendar paths on www.googleapis.com — and refuses at deploy time if it
cannot, so a Gmail grant cannot be pointed at Drive by a request the agent was
talked into making.
Hearing about it
Polling works, and webhooks are better:
const { endpoint, secret } = await vault.webhooks.create({
url: "https://api.example/hooks/seekrit-vault",
events: ["connection.ready", "connection.reauth_required", "connection.revoked"],
});
Deliveries are HMAC-SHA256-signed over the id, timestamp, and body;
verifyWebhook from @seekrit/sdk/vault checks all three and rejects
anything older than five minutes. Failed deliveries retry with backoff for
about a day. A connection.reauth_required event carries the connection's
metadata and its statusReason, which is enough to decide whether to text the
user a reconnect link now or wait for them to ask the agent for something.
What stays in the store
Retention is the current revision plus one previous, for crash recovery. Older revisions are deleted in the same transaction that writes a new one, so refresh tokens never accumulate into a history of every token a user has had. Revoking a connection — by you, by the user from a manage link, or by an org admin — deletes the ciphertext; a late write-back from a refresh that was in flight cannot bring it back, because the write is compare-and-swap against a connection that must still be active.
Revoking through the executor first tells the provider, where the preset knows how (Google's revoke endpoint, GitHub's delete an app token), then revokes in seekrit. The provider half is best effort. Seekrit refusing to release is the control that matters.
Why not build this yourself?
You can; most platforms have. The question is whether a refresh journal for rotating tokens, a single-writer per connection, scope-derived allow rules, response redaction, and signed webhooks are what your team should be building in month three, and whether the tokens they protect should be in your database at all while they do.
Vault's answer is that the lifecycle is the product and the tokens are never yours to hold. The guide covers the presets and the reconnect flow; the trust model covers why the executor is the right place for all of it.