seekrit
← all posts

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.