Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

OAuth Third-Party Connections (Server-Side Token Vault)

Third-party OAuth tokens are stored server-side in a pgsodium-encrypted column and auto-refreshed within a minute of expiry; the client never sees a raw access token; it calls an Edge Function that returns only the decrypted value for the calling user.

  • Tier: Specialized
  • Status: Template pattern
  • Config: integrations.oauthProviders

WHY IT MATTERS

Most apps that integrate with external APIs make the same mistake: they store the OAuth access token in the mobile client. It goes into AsyncStorage, SecureStore, or a local SQLite row, and the app reads it directly when it needs to call Google Calendar or the GitHub API. That approach works until it does not. Tokens in the client bundle can be extracted from device backups, read out of memory during debugging, or leaked in a log statement. More practically, when the token expires, the app has to implement the OAuth refresh flow itself, handle race conditions where two concurrent requests both try to refresh at the same time, and persist the new token without losing it. Each of those edge cases requires client-side code that touches the raw secret. The gas-template takes a different position. The raw access token and refresh token are written to the database only by a server-side Edge Function, encrypted with pgsodium via the encrypt_oauth_token RPC before the row is persisted. The mobile client never holds a plaintext token. When a screen needs to call an external API, it calls getActiveAccessToken(provider) in services/oauth.ts, which POSTs to the oauth-get-token Edge Function. That function verifies the caller's session JWT, looks up the encrypted row with a service-role client, decrypts it server-side, and returns only the plaintext value. The decrypted token never travels to a device that does not own it.

The auto-refresh mechanism is built into the same endpoint. When oauth-get-token decrypts a token and finds that it expires within the configurable threshold window (default five minutes, overridable via OAUTH_REFRESH_THRESHOLD_MINUTES), it enqueues an oauth_refresh job before returning. The job runs asynchronously in the job-queue worker, exchanges the encrypted refresh token for a new access token at the provider, and writes the updated encrypted value back to oauth_connections. A partial unique index on (user_id, provider) with status = pending means two concurrent calls within the threshold window can only enqueue one refresh job; the second enqueue raises a 23505 unique-violation that the function catches and discards. The caller always gets the current token back immediately, while the refresh proceeds in the background. By the time the next call arrives, the token is already current. This pattern removes client-side refresh logic entirely: the client calls one function, gets a valid token, and never manages token lifecycle itself.

HOW IT IS WIRED

Real code from the GAS template

The code below is drawn from services/oauth.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.

// services/oauth.ts: client surface for the server-side token vault.
// The raw access token is NEVER stored on the device; decryption happens
// inside the Edge Function, scoped to the calling user's session JWT.

export async function getActiveAccessToken(provider: OAuthProvider): Promise<string | null> {
  const { data: { session } } = await supabase.auth.getSession();
  if (!session) return null;

  const url = `${process.env.EXPO_PUBLIC_SUPABASE_URL}/functions/v1/oauth-get-token`;

  return retryWithBackoff(async () => {
    const resp = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${session.access_token}`,
      },
      body: JSON.stringify({ provider }),
    });

    if (resp.status === 404) return null; // provider not connected

    if (!resp.ok) {
      const body = await resp.json().catch(() => ({}));
      throw new Error((body as { error?: string }).error ?? `oauth-get-token failed: ${resp.status}`);
    }

    const data = await resp.json() as { accessToken: string };
    return data.accessToken;
    // Edge Function auto-enqueues oauth_refresh when token expires within
    // OAUTH_REFRESH_THRESHOLD_MINUTES (default five minutes). Client does nothing.
  }, { maxRetries: 2, baseDelay: 500, shouldRetry: shouldRetryOAuthCall });
}

Source: goodspeed-apps/gas-template services/oauth.ts

HONEST LIMITS

When OAuth Third-Party Connections (Server-Side Token Vault) is the wrong choice

This feature is specifically for delegated access to external APIs where the token must stay valid across multiple sessions and be refreshed silently in the background. If your app uses OAuth only for login, meaning the user taps Continue with Google to create a Supabase session and you never need to call a Google API on their behalf after sign-in, then the oauth_connections table and the associated Edge Functions add no value. Supabase's own PKCE OAuth flow handles login entirely and stores the Supabase session in SecureStore; the server-side token vault is a separate concern. Do not add this feature to a project where the only OAuth interaction is authentication. The refresh mechanism depends on the provider issuing a long-lived refresh token alongside the access token. Some providers, notably Google when the access_type is not explicitly set to offline, return only a short-lived access token with no refresh token. In that case the oauth_connections row will have refresh_token_encrypted set to null, and when the access token expires the connection becomes unusable until the user re-authorizes. Before enabling this feature for a given provider, confirm that the provider's OAuth consent screen includes offline access, and that your callback handler receives a refresh_token in the token exchange response. The hasRefreshToken field returned by listConnections() tells you at runtime whether a stored connection will be able to auto-refresh.

Tier: Specialized · Template pattern

  1. Evaluate your use case

    Check whether oauth third-party connections (server-side token vault) aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    The `integrations.oauthProviders` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes oauth third-party connections (server-side token vault). Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

OAuth Third-Party Connections (Server-Side Token Vault) capability breakdown

Concrete dimensions of what the built-in oauth third-party connections (server-side token vault) implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
Token storageAccess and refresh tokens are encrypted via the encrypt_oauth_token pgsodium RPC before the oauth_connections row is written. The service-role client, not the anon client, performs the upsert.pgsodium-encrypted Postgres column
Client exposureThe mobile app never holds a raw access token. It sends the user's Supabase session JWT to the Edge Function and receives only the decrypted token value in the response body.Zero raw-token exposure on client
Refresh triggerAuto-refresh is enqueued when the token is within OAUTH_REFRESH_THRESHOLD_MINUTES of expiry (default five minutes). Deduplication via partial unique index prevents concurrent re-enqueues for the same user and provider.Background job, deduped
Access gateThe oauth-get-token Edge Function uses the userHandler wrapper, which verifies the caller's Supabase session JWT and extracts userId before any database query executes. Cross-user token reads are structurally impossible.Session-JWT-scoped
Connection managementlistConnections() returns provider, expiresAt, scope, and hasRefreshToken for all of the calling user's connections. disconnectProvider() deletes the row via the RLS-scoped anon client, scoped to the calling user's user_id.Full CRUD via services/oauth.ts

COMMON QUESTIONS

How are tokens encrypted? Is pgsodium the same as pgcrypto?

The template uses a pair of Postgres RPCs, encrypt_oauth_token and decrypt_oauth_token, backed by pgsodium's secretbox primitives (XSalsa20-Poly1305). pgsodium is a separate extension from pgcrypto. pgcrypto ships with most Postgres installations and provides AES-based symmetric encryption, but does not offer authenticated encryption by default. pgsodium provides authenticated encryption, meaning decryption fails loudly if the ciphertext was tampered with, and the key is stored in a Postgres secret rather than passed as a SQL argument. The encryption key is set via the OAUTH_ENCRYPTION_KEY environment variable in the Edge Function runtime, which is the key passed to the RPC. Supabase projects have pgsodium pre-installed. If you need to rotate the key, you re-encrypt existing rows with the new key before removing the old one; the migration pattern for key rotation is documented in supabase/migrations/010.

Does this work for any OAuth provider, or only Google and GitHub?

The oauth_connections table is provider-agnostic: it stores a provider string column alongside the encrypted tokens. The gas-template ships callback handler examples for Google and GitHub (supabase/functions/oauth-callback-google/ and oauth-callback-github/), each of which calls oauth-save-connection after completing the token exchange. Adding a new provider means writing a new callback handler that completes the provider's OAuth flow and then POSTs the access token, refresh token, expiry, and scope to oauth-save-connection with CRON_SECRET authentication. The client-side code in services/oauth.ts does not change when a new provider is added. The OAuthProvider type is derived from gasConfig.integrations.oauthProviders at compile time for editor autocomplete, but the runtime behaviour accepts any string.

What happens if the refresh job fails and the token expires?

If the oauth_refresh job fails or is not processed before the access token expires, the next call to getActiveAccessToken will return a token that the external API will reject with a 401. The services/oauth.ts function has no way to know a token is expired without calling the provider API, so it returns the value as-is. Your integration code is responsible for handling a 401 from the external API and surfacing a re-authorization prompt to the user. The recommended pattern is to catch 401s from the external API call, call disconnectProvider(provider) to remove the stale connection, and show a reconnect UI that sends the user through the OAuth consent screen again. The connection is intentionally not deleted automatically on a failed refresh, because a transient provider outage should not force every user to re-authorize.

Is the decrypted token logged or cached anywhere server-side?

No. The oauth-get-token Edge Function decrypts the token inside the request handler, returns it in the HTTP response body, and the value is not written to any table, log stream, or cache. Supabase Edge Function invocation logs capture the request method, URL, and response status code but not the response body. The decrypted value exists only in the Deno runtime memory for the duration of the single request handler execution and is garbage-collected when the handler returns. If you enable verbose logging in your Edge Function for debugging, take care not to log the response body or the decrypted token string, as that would defeat the purpose of server-side encryption.

GET IT BUILT INTO YOUR APP

Score your idea and get oauth third-party connections (server-side token vault) wired in from day one