🔐↩⚡
OAuth tutorials still tell you to redirect from
/oauth/start directly. That worked in 2014. In 2026 your start endpoint is being called by a fetch from a logged-in dashboard, and the browser cannot follow a 302 with the bearer token attached. Plot twist: split your endpoints by return type.
oauth-start returns JSON with the authorize URL. The dashboard reads it and does window.location.assign(url) itself. oauth-callback is the only one that returns a 302, because Google's redirect lands there without an Authorization header anyway.
{
const { provider } = await req.json();
const cfg = PROVIDERS[provider]; // gsc | ga4 | meta
const state = crypto.randomUUID();
await saveState(state, await userIdFromAuthHeader(req));
const url = new URL(cfg.authorize);
url.searchParams.set('client_id', cfg.clientId);
url.searchParams.set('redirect_uri', cfg.redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', cfg.scopes.join(' '));
url.searchParams.set('access_type', 'offline');
url.searchParams.set('prompt', 'consent');
url.searchParams.set('state', state);
return Response.json({ url: url.toString() });
};`}>
Provider config is one record per platform. GSC, GA4, and Meta differ only in scopes and the token endpoint. Keep them in a map, not three copy-pasted functions.
Now the bit nobody covers: storing the refresh token. Putting it in a text column is a resume-generating event. Use AES-256-GCM, store the ciphertext as bytea, store the IV separately, never reuse an IV with the same key.
{
const url = new URL(req.url);
const code = url.searchParams.get('code')!;
const state = url.searchParams.get('state')!;
const { userId, provider } = await consumeState(state);
const tokens = await exchangeCode(provider, code);
const sealed = seal(tokens.refresh_token);
await db.from('oauth_tokens').upsert({
user_id: userId,
provider,
refresh_token_ct: sealed.ciphertext,
refresh_token_iv: sealed.iv,
expires_at: new Date(Date.now() + tokens.expires_in * 1000)
});
return Response.redirect(process.env.APP_URL + '/integrations?ok=' + provider, 302);
};`}>
The catch: TOKEN_ENC_KEY rotation. Once you have ciphertext in production you cannot just change the env var. Store a key_version column from day one. Future-you will send chocolates.
The Verdict: two functions, one provider map, one sealed column. Google Search Console, GA4, and Meta all fit the same shape. The only thing you have to remember is that start returns JSON and callback returns a redirect. Do not @ me.