Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

Supabase Auth with PKCE + SecureStore

Auth tokens are stored in the device keychain via ExpoSecureStore and the PKCE flow is pre-wired, so sessions survive app restarts without storing credentials in AsyncStorage.

  • Tier: Core
  • Status: Static
  • Always enabled

WHY IT MATTERS

Most React Native apps handle auth tokens by dropping them into AsyncStorage, the same key-value store used for user preferences and cached API responses. That means a session token, which grants full access to every row the user owns in your database, sits in a file readable by any process on a non-sandboxed device or any jailbroken iOS device. Expo SecureStore takes a different path: on iOS it writes to the Keychain, which uses the Secure Enclave when available and is scoped to the application bundle ID. On Android it writes to the Keystore, which hardware-backs the keys on devices that ship with a Trusted Execution Environment. Neither storage system is accessible to other apps, backup utilities, or adb shell on a production device. The GAS template wires SecureStore as the custom storage adapter for the Supabase client using the documented storage interface, so every session token Supabase writes goes directly into the keychain with no changes to calling code.

The PKCE flow is configured via flowType: 'pkce' in the createClient call. PKCE (Proof Key for Code Exchange) was designed precisely for public clients, meaning apps that cannot safely store a client secret, which is every mobile app. In the implicit flow used by older OAuth integrations, the access token appears directly in the redirect URL fragment. A malicious app registered for the same URL scheme on the same device can intercept it. PKCE eliminates this risk by having the app generate a random code verifier, hash it into a code challenge, and send only the challenge to the authorization server. The server holds the challenge and only exchanges the authorization code for a token when the app presents the original verifier, which it kept on-device throughout. No redirect URL interception is useful because the verifier never appears in the URL. The template pre-wires this flow, sets detectSessionInUrl: false (deep link callbacks are handled separately via the app scheme registered in gas.config), and enables autoRefreshToken so the JWT is silently renewed before expiry without a user-visible sign-in interruption.

HOW IT IS WIRED

Real code from the GAS template

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

// lib/supabase.ts -- PKCE client with platform-split token storage
import { createClient } from '@supabase/supabase-js';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import { gasConfig } from '../gas.config';

// Native: tokens go to iOS Keychain or Android Keystore.
// Guarded: SecureStore rejects on locked keychain or values > 2048 bytes.
// Degrade gracefully -- a missing persisted session is acceptable;
// a broken data layer is not.
const ExpoSecureStoreAdapter = {
  getItem: (k: string) => SecureStore.getItemAsync(k).catch(() => null),
  setItem: (k: string, v: string) => SecureStore.setItemAsync(k, v).catch(() => undefined),
  removeItem: (k: string) => SecureStore.deleteItemAsync(k).catch(() => undefined),
};

// Web: expo-secure-store is native-only; fall back to localStorage.
// Guarded on window -- static prerender runs in Node where window is undefined.
const WebStorageAdapter = {
  getItem: (k: string) =>
    Promise.resolve(typeof window !== 'undefined' ? localStorage.getItem(k) : null),
  setItem: (k: string, v: string) => {
    if (typeof window !== 'undefined') localStorage.setItem(k, v);
    return Promise.resolve();
  },
  removeItem: (k: string) => {
    if (typeof window !== 'undefined') localStorage.removeItem(k);
    return Promise.resolve();
  },
};

export const supabase = createClient(
  gasConfig.backend.supabase.url || 'https://placeholder.supabase.co',
  gasConfig.backend.supabase.anonKey || 'public-anon-key-placeholder',
  {
    auth: {
      storage: Platform.OS === 'web' ? WebStorageAdapter : ExpoSecureStoreAdapter,
      autoRefreshToken: true,
      persistSession: true,
      detectSessionInUrl: false,
      flowType: 'pkce', // No client secret exposed on the device
    },
  },
);

Source: goodspeed-apps/gas-template lib/supabase.ts

HONEST LIMITS

When Supabase Auth with PKCE + SecureStore is the wrong choice

The Supabase Auth layer with PKCE and SecureStore is the right default for the overwhelming majority of consumer and prosumer mobile apps. There is one category where you will need to replace rather than extend it: apps deployed into an enterprise environment that enforces a federated identity provider via SAML 2.0 or OIDC at the organization level. In that scenario, the organization's IT policy requires that authentication flow through their identity provider, such as Okta, Azure AD, or PingFederate, so that employee accounts are governed by centralized provisioning, deprovisioning, and audit logging. Supabase Auth supports Google, Apple, and a range of OAuth providers natively, and it supports PKCE for all of them, but it does not act as a SAML service provider that federates into an arbitrary enterprise IdP. If your target customer base is enterprise employees who sign in via their corporate SSO portal, you need a library that speaks SAML or enterprise OIDC directly, such as the AWS Amplify Auth module or a purpose-built enterprise authentication SDK. A second scenario worth considering: apps that run entirely offline with no cloud backend. The PKCE flow requires a round-trip to Supabase's authorization server to exchange the code for a token, and token refresh requires a live connection to Supabase. If your app is designed to work fully air-gapped, for example a field data-collection app in a region with no connectivity, the Supabase Auth client will accumulate refresh failures silently. In that specific case, an offline-first auth design using a locally-generated keypair and periodic sync when the device reconnects is a better fit. For all apps that have any connectivity at all, including intermittently connected apps that spend most of their time offline but sync when available, the template's pattern is correct: autoRefreshToken retries silently on reconnect and the SecureStore-persisted session means the user is not asked to sign in again after every offline period.

Tier: Core · Static

  1. Evaluate your use case

    Check whether supabase auth with pkce + securestore aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    This feature is always enabled. If you need to disable it, remove the corresponding template files after generation.

  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 supabase auth with pkce + securestore. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

Supabase Auth with PKCE + SecureStore capability breakdown

Concrete dimensions of what the built-in supabase auth with pkce + securestore implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
Token storageAuth tokens are written to the iOS Keychain or Android Keystore via expo-secure-store. No credentials are stored in AsyncStorage.Device keychain
Auth flowPKCE (flowType: 'pkce') ensures the code verifier never leaves the device, so mobile OAuth works without exposing a client secret.PKCE
Session auto-refreshautoRefreshToken: true silently refreshes the JWT before expiry with no user interaction required.Automatic
Web platformOn web, tokens fall back to localStorage via a window-guarded adapter. expo-secure-store is skipped to avoid crashing the static prerender.localStorage on web
Graceful degradationEvery SecureStore call is caught. A locked keychain or oversized value causes a silent no-op rather than a crash.No-crash fallback

COMMON QUESTIONS

Why use SecureStore instead of AsyncStorage for auth tokens?

AsyncStorage is a plain key-value file store backed by SQLite on iOS and a LevelDB derivative on Android. On a non-jailbroken iOS device the file lives in the app's sandbox, so it is not accessible to other apps, but it can be included in unencrypted iTunes backups, iCloud backups configured without end-to-end encryption, and developer tooling via Xcode. On Android, the situation is worse: AsyncStorage files can be read via adb backup on devices where backup is not disabled, and on rooted devices they are trivially accessible. SecureStore maps directly to the iOS Keychain and Android Keystore. On iOS, the Keychain is excluded from iTunes and iCloud backups by default (the kSecAttrAccessibleWhenUnlockedThisDeviceOnly access policy ensures tokens cannot be migrated to a new device). On Android, keys stored in the Keystore are hardware-backed on any device that shipped with a Trusted Execution Environment, which includes all Android phones manufactured after approximately 2018. The practical difference: a session token stolen from AsyncStorage gives an attacker full access to the user's account with no time limit. A session token in SecureStore requires physical device access and the device unlock credential to extract. For an app that uses Supabase Row Level Security, the session token is the only credential; there is no server-side session invalidation list to fall back on, which makes the storage decision more consequential than it would be in a traditional server-side session architecture.

Does this work with Google OAuth and Apple Sign-In, or just email/password?

Yes, the PKCE flow in the Supabase client applies to all auth methods, not just email and password. When you call supabase.auth.signInWithOAuth for Google or Apple, Supabase generates a code verifier and code challenge, redirects to the provider with the challenge in the authorization URL, and only completes the token exchange when your app presents the matching verifier on the deep link callback. The app.scheme registered in gas.config is used as the redirect URL for OAuth flows, and detectSessionInUrl: false in the client configuration means the client does not try to parse session parameters from the URL automatically. Instead, the deep link handler in the root navigation component calls supabase.auth.exchangeCodeForSession with the full callback URL once the OS delivers the deep link. This pattern is documented in Supabase's mobile OAuth guide and is the recommended approach for React Native applications. Apple Sign-In uses a slightly different path: expo-apple-authentication produces an identityToken and nonce, and services/auth.ts calls supabase.auth.signInWithIdToken with those values, bypassing the OAuth redirect flow entirely. Both paths end with a Supabase session stored in SecureStore via the adapter.

What happens to the session when the device is offline for an extended period?

The Supabase JWT has a default expiry of one hour. When autoRefreshToken is true, the client schedules a background refresh approximately one minute before the token expires by calling supabase.auth.getSession internally. If the device is online, this refresh is transparent to the user. If the device is offline when the refresh fires, the client retries on the next connectivity event. Between the token expiry and the successful refresh, calls to supabase that require authentication will fail with a 401 status. The data layer in the template handles this by checking whether the error is an auth error and, if so, calling supabase.auth.refreshSession explicitly before retrying. The SecureStore-persisted session means the refresh token is available across app restarts, so even if the device was offline for several days and the access token has long since expired, the client can silently re-authenticate using the persisted refresh token as long as it has not also expired. Supabase's default refresh token expiry is seven days but is configurable in the Supabase project settings. For apps used in environments with prolonged connectivity gaps, consider increasing this value to match the expected offline window.

How do I handle the case where SecureStore is unavailable, for example on an emulator?

The ExpoSecureStoreAdapter in lib/supabase.ts wraps every SecureStore call in a try/catch. On iOS Simulator, SecureStore is fully functional. On Android emulators, SecureStore works but hardware backing is not available since emulators do not have a TEE. In both cases the adapter functions correctly. The only scenario where getItemAsync, setItemAsync, or deleteItemAsync can throw is a locked keychain (iOS, rare in practice since the keychain unlocks when the device unlocks), a value exceeding SecureStore's 2048-byte limit (not an issue for a session token which is a compact JWT), or the app running in an environment where Expo modules are not bootstrapped such as a Jest unit test without proper mocking. In that last case, the catch block returns null or silently no-ops, and the Supabase client treats the missing session as an unauthenticated state rather than crashing. For unit tests that exercise code calling supabase directly, mock the supabase client rather than SecureStore, because the adapter is an implementation detail of the client initialization rather than something tests should interact with.

GET IT BUILT INTO YOUR APP

Score your idea and get supabase auth with pkce + securestore wired in from day one