NocoDB's OAuth token bug shows how vibe-coded SaaS apps leak Google and Slack access tokens through API responses, logs, and unscoped DB rows.

You shipped a "Connect with Google" button last week. Cursor wrote the callback handler in about ninety seconds. The handler takes the access_token Google hands back, stores it in an `integrations` row in Supabase, and returns the new row to the frontend so the UI can flip to "Connected." That response body has the access_token in it. Your browser console has it. Sentry logged it. Vercel's request log captured it. Your Supabase logs show it twice. Six places now hold a live Google token that can read your user's calendar and email until it expires in an hour, and the refresh_token sitting next to it can do that forever. NocoDB just shipped an advisory (GHSA-g72g-r7m4-9x4g) for the exact same shape of bug: OAuth tokens exposed through an endpoint that should never have returned them. If you have an `integrations` table, a `connected_accounts` table, or anything storing a `provider_token`, you probably have a version of this bug too. Let's look at where it actually lives in your code.

02The token is not a string, it's a session

Treat an OAuth access_token the way you treat your Stripe restricted key, not the way you treat a user's display name. A Google access_token with the `calendar` scope is a live API session for that person's calendar, no password prompt required. The NocoDB advisory traces back to the same misconception: tokens were stored in a row, and the row got returned through an API path that any logged-in user could hit. One ID swap in the URL and you have someone else's connection. The AI-written version of this in your app usually looks like: `const { data } = await supabase.from('integrations').select('*').eq('user_id', userId)`. That `select('*')` ships the access_token, the refresh_token, and the expiry to whoever called the endpoint. If you forgot to add a Supabase RLS policy on that table, any authenticated user can read every row in it. Run this check today. Open your repo and grep for `access_token` and `refresh_token`. For every match, ask one question: can this value end up in an HTTP response body, a log line, or localStorage? If you cannot answer no for all three, you have a leak. Pay extra attention to any route that does `select('*')` on a table holding provider tokens.

03Where vibe-coded apps ship this bug

The leak is almost never the database itself. It's the path the token takes after you store it. Three places I see it every week when I open a founder's app: The API response. Your `/api/integrations` endpoint returns the full row so the UI can render a "Connected as alex@" badge. The badge needs the email and provider name. It does not need the token. But the AI returned the whole row because that was the shortest code path, and you did not push back. The error tracker. Sentry, PostHog, Highlight, all of them capture request and response bodies by default on errors. The first time your token refresh fails, the failing response gets shipped to Sentry with the token in it. Now your error vendor has a copy of every customer's Google access_token. Their breach is now your breach. The Vercel preview. Your staging preview URL has the same `/api/integrations` endpoint, no auth, seeded with real tokens you copied from prod for testing. A preview URL leaked in a GitHub PR comment now leaks every token in that seed data. Check right now: open your Sentry project, search for `access_token` or `ya29.` (Google's token prefix). If anything matches, scrub it and rotate every token in your `integrations` table. Then add a scrubber rule before you forget.

04The fix is boring, do it anyway

Move OAuth tokens out of any table your API returns with `select('*')`. Put them in a `provider_credentials` table with an RLS policy that denies all client-side reads. Only your server, using the service role key, can touch it. Your `integrations` table keeps the safe metadata: provider name, connected email, scopes, expiry timestamp. The frontend reads from that. Tokens never leave the server. While you are in there, encrypt the token column at rest using pgsodium or your platform's equivalent. If your database backup ever leaks (and Supabase backups do leak when founders share project access loosely), the attacker gets ciphertext instead of a year of live Google sessions. Then add the scrubber. Sentry, PostHog, and Highlight all let you redact fields by name before send. Block `access_token`, `refresh_token`, `id_token`, `provider_token`, and any `Authorization` header. Set it once. Guardian scans for this exact failure mode. We hit your deployed app the way an attacker would, look for OAuth tokens leaking in API responses, check whether your `integrations` style tables are reachable without proper RLS, and flag any provider tokens showing up in publicly accessible logs or preview deployments. If you connected Google, Slack, GitHub, or Notion to your SaaS in the last six months, this is the first thing worth checking before your next launch.

The Guardian Team
Security for apps built with AI.

Find OAuth tokens leaking from your integrations table

Guardian scans your deployed app for access_tokens exposed in API responses, unscoped Supabase tables, and preview deployments, the same failure mode behind the NocoDB advisory.

Scan my app free
More articles