BUILT INTO EVERY GOODSPEED APP
Authentication
Email/password, Google, Apple, and other OAuth providers are pre-wired on Supabase Auth with a PKCE flow. Session JWTs live in the device keychain via expo-secure-store, refresh on every foreground, and drive an automatic navigation guard.
- Tier: Core
- Status: Static
- Config: features.auth.email
WHY IT MATTERS
Auth is the first thing every app needs and the easiest to get subtly wrong: tokens in the wrong store, OAuth callbacks open to replay, sessions that expire without warning. The template wires it on Supabase Auth with a PKCE flow (flowType: 'pkce' in lib/supabase.ts) and a platform-split storage adapter, so session JWTs persist in the iOS Keychain or Android Keystore through expo-secure-store on device and in localStorage on web. Email/password, Google, Apple, and the other OAuth providers (Twitter, LinkedIn, Microsoft) are each gated by a flag under gasConfig.features.auth, so a provider you do not enable never renders a button. The useAuth hook in hooks/useAuth.ts owns all session state through supabase.auth.onAuthStateChange and is the single source of truth for who is signed in.
Two details that usually surface later as bug reports are handled up front. Web-based OAuth carries a random state token generated by generateAndStoreOAuthState() and checked by verifyAndClearOAuthState() before the code is exchanged, so a stale or forged callback cannot complete a sign-in. Apple Sign-In generates a 32-byte nonce, sends only its SHA-256 hash to Apple, and forwards the raw value to Supabase, which binds the identity token to that nonce and blocks token replay. The navigation guard is automatic: a useEffect in app/_layout.tsx watches the session and redirects an unauthenticated user out of the tabs to /(auth)/login and a signed-in user out of the auth stack into the app, so no screen carries its own guard. On every foreground the hook calls supabase.auth.getSession() to refresh a stale JWT before the user touches anything.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from services/apple-auth.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// services/apple-auth.ts
export async function signInWithApple(): Promise<{ userId: string; email: string | null }> {
if (Platform.OS !== 'ios') {
throw new ServiceError('apple_auth_unavailable', 400, 'Sign-in with Apple is iOS-only');
}
const { raw: rawNonce, hashed: hashedNonce } = await generateNonce();
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
nonce: hashedNonce,
});
if (!credential.identityToken) {
throw new ServiceError('apple_auth_no_token', 401, 'Apple did not return an identity token');
}
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: credential.identityToken,
nonce: rawNonce,
});
if (error || !data.user) {
throw new ServiceError('apple_auth_supabase_failed', 401, error?.message ?? 'Sign-in failed');
}
return { userId: data.user.id, email: data.user.email ?? null };
}Source: goodspeed-apps/gas-template → services/apple-auth.ts
HONEST LIMITS
When Authentication is the wrong choice
Reach for a different identity layer when the requirement is enterprise SSO. The config schema models OAuth and OIDC providers (Google, Apple, Twitter, LinkedIn, Microsoft/Azure), not SAML or WS-Federation, and there is no SCIM provisioning path. Apps that must support IdP-initiated SSO, just-in-time provisioning, or directory sync against Okta or Entra need a dedicated enterprise identity vendor in front of, or instead of, Supabase Auth. Standard social and email sign-in for a consumer or prosumer app is exactly what this is built for. Also note that the auth path assumes connectivity. The client refreshes the session on every app foreground with a live call to Supabase, and there is no offline session-validation path; an expired token with no network leaves the last cached session in place until the next successful refresh, and that refresh failure is swallowed rather than surfaced. For an app that must make trust decisions while fully offline, pair this with your own signed, time-boxed local claims rather than relying on the Supabase session alone.
Tier: Core · Static
Evaluate your use case
Check whether authentication aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.auth.email` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
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
Apps built with Authentication
These apps were generated by Goodspeed and use authentication as a core part of their experience. Each link goes to the full app marketing page.
CAPABILITIES
Authentication capability breakdown
Concrete dimensions of what the built-in authentication implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Providers | Email/password via signInWithPassword, Google and other OAuth via PKCE + WebBrowser.openAuthSessionAsync + exchangeCodeForSession, and Apple via native expo-apple-authentication + signInWithIdToken. | 5+ providers |
| Token storage | Native sessions persist in the iOS Keychain / Android Keystore through expo-secure-store; web uses localStorage. persistSession and autoRefreshToken are both on. | SecureStore |
| OAuth replay defense | Web OAuth stores a random state in SecureStore and verifies the echoed value before exchangeCodeForSession runs; the state clears on every check so a stale callback cannot be replayed. | State + PKCE |
| Automatic nav guard | A useEffect in app/_layout.tsx redirects an unauthenticated user out of the tabs to /(auth)/login and a signed-in user out of /(auth) into the app. No per-screen guard code. | Built in |
| Foreground refresh | useAuth's AppState listener calls supabase.auth.getSession() on every foreground when a session exists, forcing an eager JWT refresh so a stale token is caught before the user interacts. | On-foreground |
COMMON QUESTIONS
Where are auth tokens stored, and how secure is that?
On native, session JWTs are stored through expo-secure-store, which maps to the iOS Keychain and the Android Keystore, so only your app bundle ID can read them. Each read and write is wrapped in try/catch; if the keychain is locked or a value exceeds the platform limit, the adapter degrades to in-memory rather than throwing. On web, tokens live in localStorage. The Supabase client is created with persistSession and autoRefreshToken on, so the SDK refreshes tokens in the background, and useAuth additionally forces a getSession() refresh on every app foreground.
Does OAuth use PKCE, and how is the callback protected?
Yes. The Supabase client is created with flowType: 'pkce'. On top of PKCE, the template adds its own CSRF state: generateAndStoreOAuthState() writes a random value to SecureStore before the browser opens, and verifyAndClearOAuthState() checks the state echoed back on the deep-link callback before exchangeCodeForSession runs, clearing it so it cannot be reused. That gives you two independent replay defenses on the redirect rather than relying on the provider alone.
What does Apple Sign-In require to set up?
Four things: set gasConfig.features.auth.apple to true; keep the expo-apple-authentication module that ships in the template; configure the Sign In with Apple entitlement for your bundle ID in your Apple Developer account; and enable Apple as a provider in your Supabase project with that bundle ID registered. No server secret is embedded in the app bundle, because the flow exchanges Apple's identity token for a Supabase session via signInWithIdToken. The button only renders on iOS where AppleAuthentication.isAvailableAsync() returns true, and is a no-op on Android and web.
How does session refresh work when the app has been backgrounded for a while?
The Supabase client handles routine refresh through autoRefreshToken. On top of that, useAuth registers an AppState listener that calls supabase.auth.getSession() on every transition to foreground when a session exists, which forces an eager refresh and catches a JWT that went stale while the app was backgrounded. If that refresh call fails because there is no network, the error is caught and the last known session stays in state until the next successful refresh, so the user is not bounced to the login screen by a momentary connectivity gap.
GET IT BUILT INTO YOUR APP