The One-Hour OAuth Bug

July 2, 2026

We had about a hundred B2B customers on Otto. Real inboxes. Real calendars. Real "I need this to work before my next call" energy.

Then we migrated ottosales.ai to Clerk's production instance.

Same Clerk account in the dashboard. Not the same user database underneath.

Same dashboard account. Separate user databases underneath.

Clerk does not let you promote dev users into prod. Their docs say it directly: you cannot migrate users from your Development instance to your Production instance. Dev and prod are isolated. Separate user ids. Separate encrypted OAuth secrets. Separate API keys.

So the migration was manual:

  1. Read our user list from Postgres (email, name, org membership)
  2. Create each user again in Clerk prod via the Backend API
  3. Store Otto's user id as external_id on the new Clerk user
  4. Update users.clerk_user_id in Postgres to the new prod Clerk id
  5. Deploy ottosales.ai with prod keys and prod Clerk frontend config
  6. Ask users to sign in again and reconnect Google/Microsoft

That last step is easy to underestimate. From Otto's point of view, the Postgres row is the source of truth for deals, emails, and org data. Clerk is the auth layer on top. After migration, each row needed a new clerk_user_id pointing at prod, not the dev id it had before.

#simplified shape of the link we had to fix
users.id              → Otto's primary key (deals, sync state, etc.)
users.clerk_user_id   → must now be a prod Clerk user id
Clerk external_id     → set to users.id when we created the prod user

Users who had connected Google on dev now had fresh Clerk prod accounts. Their Google external account had to be rebuilt on prod too. That is where the OAuth pain started.

The deploy looked fine on the surface. Users logged in. Google accounts showed as connected. Nothing caught fire in the first hour.

A few hours later, Google integrations started asking users to reconnect. They would reconnect. Things worked. Then, about an hour later, the same reconnect prompt came back.

One hour is a very specific bug interval. When support tickets arrive on a clock, you stop guessing about scopes and start counting token lifetimes.

two tokens, two jobs

Google OAuth gives you two different things:

access_token   → works for ~1 hour, then dies
refresh_token  → long-lived, used to get new access tokens

Our backend does not store Google refresh tokens. Clerk holds those. We cache the access token in Postgres and ask Clerk for a fresh one when the cache runs out:

# Otto caches Google access tokens for 55 minutes
GOOGLE_TOKEN_CACHE_TTL = 55 * 60

So the loop looked like this:

  1. User reconnects Google
  2. Clerk returns an access token
  3. Otto syncs Gmail/Calendar for ~55 minutes
  4. Cache expires
  5. Otto asks Clerk for a new access token
  6. Clerk fails
  7. User sees "reconnect Google" again

If step 6 keeps happening, Clerk probably does not have a refresh token for that user.

the sync jobs that found it first

Otto is not just a web app you open and close at ottosales.ai. It runs background sync jobs on a schedule. Celery Beat dispatches them even when nobody has a tab open.

These are the ones that hit Google OAuth the most:

Email import     → every hour at :00
Calendar import  → every hour at :15
Contacts import  → once daily at 6:30 AM UTC

Each job fans out per user. For every active user with Google connected, a worker wakes up, asks Clerk for a Google access token, then calls the Gmail, Calendar, or Contacts API to pull new data into Otto.

That is the product working as designed. Your inbox keeps updating. Your calendar stays current. Meeting prep and deal signals run off that fresh data.

It also means the bug showed up in workers before it showed up in the UI. A user could reconnect, browse around, and feel fine. Then the hourly email sync would run, ask Clerk for a token, get oauth_missing_refresh_token, and mark the connection as needing reconnect.

The one-hour interval in the bug report was not random. Google access tokens last about an hour. Our cache TTL is 55 minutes. The email sync runs every hour on the hour. Those three clocks line up almost perfectly.

Three clocks lining up: sync on the hour, cache at :55, failure at the next :00.

what production logs said

We checked production Cloud Logging. Clerk was returning this:

code: oauth_missing_refresh_token
message: authorization server hasn't provided us with a refresh token

The errors started right after we migrated from Clerk dev to Clerk prod on June 22. They showed up mostly in Celery workers (otto-celery-default-pool, otto-celery-low-pool), not in the web API. That matched the sync schedule above.

That error code was the whole story in one line: Clerk knew the Google account existed, but it could not refresh it.

the reconnect button lied politely

We have a reconnect flow in settings. In our frontend we pass the two parameters Google needs for offline access:

await startClerkOAuth(user, {
  clerkProvider: "google",
  scopes,
  redirectUrl,
  oidcPrompt: "consent",
});

Two parameters matter here:

access_type=offline   → "give me a refresh token"
prompt=consent        → "show the consent screen again"

access_type=offline alone is not enough if Google already approved your app. Google may say "sure, here is another access token" and skip the refresh token.

prompt=consent is what forces Google to treat the flow like a fresh grant.

Our code asked for both. The browser URL told a different story.

When an affected user clicked reconnect, Google showed account selection. Not consent. The live URL looked like this:

access_type=offline
prompt=select_account    ← not consent

Account selection asks: which Google account?

Consent asks: do you still want to grant offline access?

Only the second question can produce a new refresh token. We kept getting the first one.

So the user did the right thing. Our app sent the right intent. Google did what the final URL asked. Something between our code and Google rewrote the request.

it was actually a bug in clerk

We thought we had a migration problem. Then we shipped the fix.

We changed reconnect to pass oidcPrompt: "consent". We deployed it. We watched a user click reconnect. Google still showed the account picker. Still no consent screen. Still prompt=select_account in the URL.

At that point the bug stopped feeling like OAuth weather and started feeling like something was eating our parameters on the way out.

We traced the settings reconnect path through our shared OAuth helper:

// frontend/lib/clerk-oauth-client.ts
const account = existingAccount
  ? await existingAccount.reauthorize({ ...options })
  : await user.createExternalAccount({ ...options });

Both calls take oidcPrompt. TypeScript is happy. The IDE autocompletes it. Clerk's own docs list it on ExternalAccount.reauthorize(). The shared types match:

// ReauthorizeExternalAccountParams — from @clerk/types
{
  additionalScopes?: string[];
  redirectUrl?: string;
  oidcPrompt?: string;   // documented, typed, blessed
}

So we did not hallucinate the fix. We implemented exactly what Clerk said the API accepts.

Then we opened the runtime.

Production loads @clerk/clerk-js@5. In the actual browser bundle, ExternalAccount.reauthorize() does this:

// clerk-js runtime — what actually gets sent
return patch("reauthorize", {
  additional_scope: additionalScopes,
  redirect_url: redirectUrl,
  // oidcPrompt? never read. never sent.
});

Same story for User.createExternalAccount(). The method signature accepts the parameter. The serializer ignores it.

That is the part that made the room go quiet.

We sent consent. Clerk's types agreed. The runtime deleted it before Google saw it.

We were not fighting Google policy at that moment. We were fighting our own auth SDK silently deleting the one field that mattered.

The redirect URL was the receipt. Scopes were there. Redirect URL was there. Offline access was there. The consent prompt was gone.

Google did the rational thing. It saw an app the user had already approved and handed back a one-hour access token. No new refresh token. Reconnect looked successful. The hourly loop came back on schedule.

That is the full chain:

  1. Dev → prod migration left prod Clerk users without usable refresh grants
  2. User clicks reconnect
  3. Frontend passes oidcPrompt: "consent"
  4. Clerk SDK accepts it in types, drops it in runtime
  5. Google skips consent
  6. Google returns access_token only
  7. Sync works for ~1 hour
  8. Loop repeats

The cruel part: every layer above the SDK told us we were doing the right thing. Postgres was fine. Deploy was fine. Types were fine. Docs were fine. The bug lived one function below all of that, in code we never wrote.

how Google refresh tokens actually work

Google does not hand out a new refresh token on every login. That would let apps stockpile them.

The usual rules:

First consent + access_type=offline  → refresh token issued
Later login, same grant            → access token only
Lost refresh token                 → need consent again OR revoke first

Google's own docs say that if you lose a refresh token, you need either prompt=consent or the user must revoke the app from their Google account settings.

That second option sounds dramatic. It is also the fastest manual fix when your SDK will not send prompt=consent:

  1. User opens Google Account → Security → Third-party access
  2. Removes Otto (ottosales.ai)
  3. Logs into ottosales.ai again
  4. Google shows full consent
  5. Google issues a new refresh token

Deleting the Google external account inside Clerk does not always do the same thing. Clerk forgets the link. Google may still remember the old grant. Reconnect can look successful and still skip consent.

what we fixed

On the app side, we made reconnect explicitly request offline access and consent. That protects new connections and any flow where those parameters actually reach the provider.

For users already stuck, the repair path is manual but reliable:

  1. Revoke Otto (ottosales.ai) from Google third-party app access
  2. Disconnect the external account in Clerk if needed
  3. Log in again
  4. Confirm Google shows the consent screen (not just account picker)
  5. Wait past the one-hour mark and verify sync still runs

Step 5 is the one people skip. We learned that the hard way. A reconnect that works for five minutes is a demo. A reconnect that works at minute 65 is a fix.

The longer-term fix is to avoid a wrapper path that drops the consent request, and use a path where the consent prompt actually reaches Google. Until then, revocation is the honest workaround.

how the loop stopped

The migration recreated Clerk users without usable Google refresh grants. Clerk's SDK accepted oidcPrompt in types and dropped it before the request left the browser. Each looked fine on its own. Together they read as one OAuth problem until the hourly sync pattern narrowed it down.

We fixed the stuck users by revoking stale Google grants and routing consent through a path that actually reaches the provider. That closed the incident.