How RapidNative Handles Authentication in AI-Generated React Native Apps

(156 characters):

RI

By Riya

8th Sep 2026

Last updated: 8th Sep 2026

How RapidNative Handles Authentication in AI-Generated React Native Apps

Ask ten developers what breaks first in an AI-generated mobile app and most will say "the UI." They're wrong. The first thing that breaks — quietly, invisibly, and without a stack trace — is authentication. A screen renders. A list loads. Zero rows come back. No error appears in the console. The user sees an empty state and assumes the AI failed to write the query. In reality, the query is perfect. The auth policy is missing.

This silent-deny problem is the single most common failure mode in AI-generated apps that talk to a real database. At RapidNative, we've built the entire authentication pipeline around preventing it. This post walks through the exact architecture our AI agent uses to generate secure Supabase authentication React Native apps that ship with working auth on the first prompt — not the third.

Developer working on mobile app authentication code Photo by Markus Spiske on Unsplash

Why Authentication in AI-Generated Apps Is a Hard Problem

Traditional codegen tools treat authentication like any other feature: generate a sign-in screen, drop a Supabase client somewhere, hope the developer wires up the rest. That works for tutorials. It fails in production the moment a Row Level Security (RLS) policy is missing, a redirect URL is wrong, or a session refresh fires while the app is backgrounded.

Auth in a mobile app spans four layers, and getting any one of them wrong looks identical from the user's perspective:

  1. Client layer — how the app talks to Supabase (which storage backs the session, when tokens refresh)
  2. Route layer — which screens are gated behind auth, how redirects behave when a session is loading
  3. Database layer — what RLS policies exist on each table and whether auth.uid() is scoped correctly
  4. Validation layer — how the AI knows, at generation time, that the schema it just wrote will actually return data

Most AI code generators only cover layer one. RapidNative covers all four, and does layer four before writing files to disk.

The Three-Layer Auth Architecture

Every fullstack project RapidNative generates ships with a three-layer auth stack: a Supabase client wrapped in React Context, Row Level Security policies validated at migration time, and Expo Router route groups gating protected screens. Nothing is mocked. Nothing is stubbed. The preview environment uses the same code that ships to the App Store.

Here's how the layers connect:

LayerResponsibilityTechnology
ClientSession state, sign-in/up/out, token refresh@supabase/supabase-js v2.90+, AsyncStorage
RoutingGate protected screens, redirect logicExpo Router 57+ route groups (auth) / (app)
DatabaseEnforce per-user data isolationPostgres RLS policies + auth.uid()
ValidationBlock broken migrations before writePGlite (real Postgres in WASM) + lint rules

Layer 1: The Client — One Supabase Client, Reached Through Context

Every generated app has exactly one Supabase client, created once at app boot in mobile/src/db/client.ts:

export const supabase = createClient<Database>(url, anonKey, {
  auth: {
    storage: Platform.OS === 'web' ? undefined : AsyncStorage,
    persistSession: true,
    autoRefreshToken: true,
    detectSessionInUrl: false,
  },
});

Four decisions worth explaining:

  • AsyncStorage, not expo-secure-store. The mobile-security best practice is SecureStore, but SecureStore is synchronous-only on iOS Keychain and adds a hard 2KB per-key limit. Supabase sessions with refresh tokens routinely exceed that. AsyncStorage trades encryption-at-rest for reliability. For the majority of consumer apps this is the right trade; apps that require Keychain-level protection swap the storage adapter with one line.
  • persistSession: true. Sessions survive app restarts. The user signs in once.
  • autoRefreshToken: true. When an access token expires (default 1 hour), the SDK exchanges the refresh token silently on the next query.
  • detectSessionInUrl: false. This one bites people. Supabase's default is to auto-consume tokens from URL fragments — designed for web. On mobile with Expo, deep-link consumption is the app's job, not the SDK's. Leaving this on causes duplicate consumption and race conditions during password reset and OAuth flows.

The client is passed through a React Context provider (AppProvider), so every screen and hook accesses it the same way: const { client } = useApp(). There is no global, no singleton import from a lib file, no per-component createClient call. One client, one context, one place to instrument or swap.

The useAuth() Hook

Instead of screens calling client.auth.signInWithPassword(...) directly, every generated app exposes a useAuth() hook that wraps auth operations in TanStack Query mutations:

const { signIn, signUp, signOut, session, isAuthenticated, isLoading } = useAuth();

// In a screen:
signIn.mutate({ email, password }, {
  onError: (err) => setErrorMessage(err.message),
});

Why the wrapper? Three reasons: (1) auth mutations automatically invalidate the session query, so gated screens re-render the moment auth state changes; (2) signOut clears every non-auth query from the cache, preventing the "previous user's data flashes for 200ms" bug; (3) isLoading from the session query gives screens a clean way to render a spinner instead of flickering between signed-in and signed-out states.

Layer 2: Route Groups — Gating Screens with Expo Router

Expo Router route groups make auth gating declarative. The generated file structure looks like this:

app/
├── _layout.tsx           # Root guard: redirects based on auth state
├── (auth)/
│   ├── _layout.tsx
│   ├── sign-in.tsx
│   └── sign-up.tsx
└── (app)/
    ├── _layout.tsx
    ├── index.tsx         # Protected home screen
    └── profile.tsx       # Protected profile screen

The root _layout.tsx reads auth state from useAuth() and issues a <Redirect> — not in a useEffect, in the render tree itself. That distinction matters. useEffect-based redirects fire after the protected screen has already mounted, briefly exposing it. Render-time redirects prevent the flash entirely.

export default function RootLayout() {
  const { isAuthenticated, isLoading } = useAuth();
  const isDesigner = process.env.EXPO_PUBLIC_RAPIDNATIVE_MODE === 'designer';

  if (isLoading) return <SplashScreen />;
  if (!isAuthenticated && !isDesigner) return <Redirect href="/(auth)/sign-in" />;
  return <Stack screenOptions={{ headerShown: false }} />;
}

The isDesigner exception is subtle and important. When a designer is editing an auth screen inside the RapidNative editor, the preview is auto-signed-in as a demo user (more on this below). Without the exception, the editor would immediately redirect away from sign-in.tsx the moment auth loaded, making it impossible to edit. We discovered this the hard way.

Team reviewing mobile app architecture diagrams Photo by Annie Spratt on Unsplash

Layer 3: Row Level Security — Where Auth Actually Lives

In a Supabase app, RLS is not a nice-to-have. It's the entire enforcement mechanism. The mobile client ships with the anon key baked into EXPO_PUBLIC_SUPABASE_ANON_KEY — the anon key is public, embedded in a public bundle, and anyone who ships the app hands it out to every user. What keeps users from reading each other's data is a Postgres policy tied to auth.uid().

A canonical policy set for a user-scoped table:

alter table workouts enable row level security;

create policy workouts_select on workouts 
  for select using (auth.uid() = user_id);

create policy workouts_insert on workouts 
  for insert with check (auth.uid() = user_id);

create policy workouts_update on workouts 
  for update using (auth.uid() = user_id);

create policy workouts_delete on workouts 
  for delete using (auth.uid() = user_id);

Every generated migration for a user-scoped table produces this policy family automatically. auth.uid() reads the sub claim from the JWT that Supabase attaches to every request, matches it against the user_id column, and returns only the rows that belong to the signed-in user. When the client sends a select * from workouts, Postgres silently rewrites it to select * from workouts where auth.uid() = user_id. The client never sees the filter. It just sees fewer rows.

The Silent Deny Trap

Here is the failure mode that defined our entire auth architecture. Consider this migration:

create table workouts (
  id uuid primary key,
  user_id uuid references auth.users(id),
  title text
);
alter table workouts enable row level security;
-- (developer forgets to add policies)

RLS is enabled. No policy exists. What happens when the app queries workouts?

Zero rows. No error. No warning. Every query returns an empty array. The generated UI renders "No workouts yet." The user assumes the AI messed up the schema. The developer opens the Supabase dashboard, sees the row they inserted directly, and cannot understand why the client can't see it. The answer: RLS defaults to deny when no policy allows.

This bug is invisible from the client. It's invisible in the server logs. It's only visible if you know to look at pg_policies for the table. Real users report it as "the app is broken." Real developers spend hours debugging queries they wrote correctly.

Layer 4: Validation — Catching Auth Bugs Before They Ship

The unique thing RapidNative does — the thing no other AI app builder does — is validate the RLS state of every migration before writing the file to disk. Our AI agent's db_migration_new tool runs the proposed SQL through a real Postgres engine (PGlite compiled to WASM), then lints the resulting schema. One of the lint rules:

if (info.rlsEnabled && info.policies.length === 0) {
  lints.push({
    level: 'error',
    table,
    message:
      `"${table}" has RLS enabled but no policies, so every query returns zero rows and the app ` +
      `will look broken with no error. Add a policy in the same migration.`,
  });
}

If a migration would leave a table in the silent-deny state, the agent sees an error, backs up, and regenerates the migration with the missing policies. The file never lands on disk. The user never sees the broken screen. What could have been a 45-minute debugging session becomes a 2-second retry.

Why PGlite, Not pg-mem

Our original validation engine was pg-mem — an in-memory Postgres subset. It was fast and lightweight. It also lied. Real Postgres has no uuid = text operator; pg-mem does. A migration that declared profiles.id text with a policy auth.uid() = id would pass pg-mem, get written to disk, then fail on the first CREATE POLICY in production Supabase. The result: no tables, no seed, every screen empty, no error anywhere the user could see it.

We ripped pg-mem out and replaced it with PGlite — the real Postgres source compiled to WebAssembly. If PGlite accepts the migration, real Supabase accepts it. If PGlite rejects a policy for a type mismatch, we surface the exact error to the agent and force it to fix the schema. A validator more permissive than the runtime cannot do its job.

PGlite also enforces GRANTs, so our bootstrap script grants anon and authenticated table access exactly the way Supabase does. Without that, every queryAsUser() test would fail on privileges before a policy was ever consulted — and we'd have no way to test whether the policy itself is correct.

Server room representing production infrastructure Photo by Taylor Vick on Unsplash

Preview and Production: The Same Auth Stack

Here's the part most AI app builders skip: our preview environment runs real Supabase, not a mock.

When a user opens their project in the RapidNative editor, our orchestration layer (orchd) provisions a real Postgres instance (we call it tinbase) with the user's schema applied, seed data loaded, and RLS policies enforced. The mobile preview receives:

EXPO_PUBLIC_SUPABASE_URL=https://<workload-id>.rapidnative.app
EXPO_PUBLIC_SUPABASE_ANON_KEY=<real anon key>
EXPO_PUBLIC_RAPIDNATIVE_MODE=staging

Every auth action — sign-in, sign-up, password change, session refresh — hits real Supabase auth. RLS policies enforce real isolation. What renders in the preview is what will render in the App Store build. There is no "it worked in the editor but broke on device" gap, because the editor and the device are running against the same backend.

The Demo User Trick

But if the preview requires sign-in and the whole point of the editor is to edit the sign-in screen, we have a chicken-and-egg problem. Solved by auto-signing in a demo user at preview boot:

if (process.env.EXPO_PUBLIC_RAPIDNATIVE_MODE === 'designer' || 
    process.env.EXPO_PUBLIC_RAPIDNATIVE_MODE === 'staging') {
  void supabase.auth.getUser().then(async ({ data, error }) => {
    if (!error && data?.user) return;
    await supabase.auth.signInWithPassword({
      email: 'demo@rapidnative.com',
      password: 'rapidnative-demo',
    });
  });
}

The demo user (id: 00000000-0000-0000-0000-000000000001) is provisioned by the seed script. It exists in every generated project. All seed data is owner-scoped to this ID, so the preview shows real rows through real RLS — no mocking, no data-layer bypasses. When the app is exported for production, the seed and demo user go with it. The developer can delete them or leave them as test fixtures.

Deep Linking and OAuth on Mobile

OAuth on mobile is a swamp of URL schemes, redirect URIs, and platform-specific quirks. The generated apps handle it with two rules:

Rule one: never hardcode the scheme. The redirect URL differs between Expo Go (exp://<host>/--/), production builds (rapidnative-app://), and web (https://<host>/). The generated code uses Linking.createURL() from expo-linking, which returns the right one for the current runtime:

const redirectTo = Platform.OS === 'web'
  ? `${window.location.origin}/auth/callback`
  : Linking.createURL('auth/callback');

await client.auth.signInWithOAuth({
  provider: 'google',
  options: { redirectTo, skipBrowserRedirect: true },
});

Rule two: consume the token manually. Because we set detectSessionInUrl: false, the app must intercept the redirect and pass the token to Supabase itself:

const { token_hash } = useLocalSearchParams();
const { error } = await client.auth.verifyOtp({ token_hash, type: 'recovery' });

Password reset works the same way. Magic links work the same way. The pattern is identical because the token exchange is identical.

Session Refresh, Offline, and the App Foreground Lifecycle

Mobile apps get backgrounded. Users close them. Networks drop. When any of that happens, an access token can expire while the app is not running. RapidNative-generated apps handle this with a useOffline hook that listens for AppState and NetInfo changes:

useEffect(() => {
  const unsubscribe = NetInfo.addEventListener((state) => {
    const online = !!state.isConnected && !!state.isInternetReachable;
    onlineManager.setOnline(online);
  });
  return unsubscribe;
}, []);

When the app returns online, TanStack Query automatically retries failed queries. When Supabase's SDK detects an expired token on the next request, it silently exchanges the refresh token for a new access token. Neither the developer nor the user does anything.

For production builds, the query cache is persisted to AsyncStorage — with one exception. Auth queries are explicitly excluded from persistence:

dehydrateOptions: {
  shouldDehydrateQuery: (query) => {
    if (query.state.status === 'error') return false;
    if (query.queryKey[0] === 'auth') return false;  // Never cache auth
    return true;
  }
}

Auth state is checked fresh on every app boot. Everything else is served from cache until proven stale.

Anti-Patterns the Agent Explicitly Avoids

The generated code refuses to do certain things, because we've watched every one of them break real apps:

  1. Reaching the client through a global. (globalThis as any).__RAW_SUPABASE__ is not set anywhere; the pattern is dead code. Use the context.
  2. Calling client.auth.* directly in screens. Bypasses the query invalidation that keeps the auth-gated route re-renders in sync.
  3. Auto-redirecting signed-in users off auth screens. Breaks the editor preview and doesn't actually improve UX (a signed-in user hitting /sign-in deliberately is usually trying to switch accounts).
  4. Alert.alert as the only error feedback. Alert.alert does nothing on web. If the preview breaks silently, developers can't fix what they can't see. Render errors into on-screen state.
  5. crypto.randomUUID() for client IDs. Hermes has no global crypto. Use expo-crypto.

Every one of these rules was born from a bug report. The system prompt encodes them. The AI agent will not generate code that violates them.

What This Means for Developers Using RapidNative

If you build an app on RapidNative, your first prompt gives you an app with:

  • A working email/password sign-in and sign-up flow
  • Session persistence across app restarts
  • Automatic token refresh
  • Row Level Security on every table, with correct auth.uid() policies
  • Route groups that gate protected screens without flashing content
  • A demo user pre-provisioned so you can preview auth-gated screens immediately
  • Real Supabase running behind the preview — nothing mocked

When you export the project and publish to the App Store, the same auth stack ships. The .env values point at your own Supabase project instead of tinbase. Everything else is identical.

The point of all this architecture is not "we generate auth code." Any AI can generate auth code. The point is that the auth we generate is validated — schema-checked against real Postgres, lint-checked against real security invariants, preview-tested against real user sessions — before you ever see it. The failure modes that turn AI-generated apps into blank screens don't happen because we made them impossible to happen.

If you'd like to see the pattern in a working project, start building an app and inspect the generated useAuth hook, RLS policies, and route layout. It's the same code every generated project ships with. If you want the broader context on how the fullstack template works, our post on the backend and frontend in one prompt covers the schema-generation side, and our state management deep-dive explains the TanStack Query patterns that make the auth hook reactive.

Authentication is the layer where AI-generated apps most often fail silently. It's also the layer where careful architecture pays off most visibly: you either have working auth on the first prompt, or you spend a weekend debugging why your queries return empty arrays. RapidNative is built to make sure it's the first one.

Start now

Ready to build your app?

Turn your idea into a production-ready React Native app in minutes.

Free tools to get you started

Questions

Frequently asked questions

What is RapidNative?

RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.

Can I export the code?

Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.

Is RapidNative free to use?

Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.

Do I need to know how to code?

No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.

How long does it take to build an app?

Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.