User Authentication Flow in Expo and Supabase

Build a secure user authentication flow in Expo with Supabase and Hono. Covers sign-up, sign-in, OAuth, tokens, and best practices.

Profile photo of RishavRishav
15th Sep 2026
Featured image for User Authentication Flow in Expo and Supabase

You ship an Expo app, wire Supabase Auth directly into a few screens, and move on to the feature users asked for. Six weeks later, a stolen JWT leads you through client logs, recovery code, profile rows, and an OAuth callback nobody remembers owning. The login screen looked small, but the user authentication flow became a distributed system with security, data consistency, and recovery behavior.

This guide uses a different boundary. Expo handles the interface and protected token custody, Supabase remains the identity provider and database, and a Hono edge API fronts every authentication operation. Sign-up, sign-in, OAuth callbacks, refresh, and recovery pass through one routed pipeline instead of scattered client-side calls.

Table of Contents

Why a Solid User Authentication Flow Matters in Your Expo App

Authentication deserves the same architectural care as your data model. A user record, a session, and a profile row have different lifecycles, and your app needs explicit rules for how those records are created, updated, revoked, and recovered.

A common first version calls supabase.auth.signUp() directly from an Expo screen, stores whatever session object comes back, and lets each feature decide how to attach credentials. That approach can work for a prototype, but it spreads validation, rate limiting, authorization, and error handling across the client. It also makes accidental exposure of server-only credentials much easier.

A diagram illustrating the importance of secure user authentication flows for an indie development application.A diagram illustrating the importance of secure user authentication flows for an indie development application.

Give each layer one job

Expo owns forms, loading states, deep links, and secure local custody. Use expo-secure-store for refresh credentials and other sensitive session material rather than treating ordinary application storage as a vault.

Supabase Auth verifies credentials, manages provider integrations, and issues Supabase sessions. Postgres stores application data, including a profiles row that should be linked to the authenticated user.

Hono provides the enforcement point. It can validate request bodies, apply rate limits, verify bearer tokens, normalize errors, and log security events before a request reaches application data.

Practical rule: The client can request authentication, but it shouldn't decide whether a session is valid or whether a protected resource belongs to the current user.

This boundary also makes recovery easier to reason about. Passwords, email links, OTPs, and device-based credentials each have different failure modes. SMS can be useful as a fallback, but SIM swapping, interception, and delivery problems mean you should understand the risks of SMS verification codes before making them your primary protection.

For a broader comparison of password, magic-link, social, and device-based approaches, see this guide to user authentication methods. The rest of the implementation treats every route as part of one pipeline, including sign-up, sign-in, refresh, OAuth, and account recovery.

Setting Up Expo, Hono, and Supabase for the Flow

Start with three deliberately boring projects. Create the Expo app, add expo-secure-store and expo-auth-session, then create an edge-ready Hono service for Cloudflare Workers or Vercel Edge. Keep the Supabase project separate from the mobile bundle, and never place a service-role secret in an EXPO_PUBLIC_ variable.

A useful layout keeps the client, edge routes, and shared contracts visible:

app/
  api/
    auth/
      [...route].ts
lib/
  supabase.ts
  auth-storage.ts
shared/
  auth-types.ts
server/
  auth.ts

The catch-all route can expose /api/auth/health, /api/auth/signup, /api/auth/signin, /api/auth/refresh, and the OAuth callback under one Hono router. lib/supabase.ts should contain the client singleton used for session-aware operations, while shared/auth-types.ts defines Session, AuthResponse, and the Hono environment bindings.

Keep environment boundaries explicit

The mobile app may know EXPO_PUBLIC_SUPABASE_URL, because it identifies the Supabase project. It must never know SUPABASE_SERVICE_ROLE_KEY. That value belongs only in the edge deployment environment. Your Hono JWT middleware also needs the signing configuration required to verify the tokens it accepts. Keep those bindings typed, and fail startup when a required secret is missing.

Supabase should already contain the application tables and Row Level Security policies before you wire screens. A typical profiles table uses the Supabase Auth user ID as its owner key. RLS should derive ownership from the authenticated request, not from an arbitrary user ID supplied by the mobile client.

The same discipline applies to transactional email. Confirmation and recovery messages are part of the flow, so decide whether Supabase sends them directly or whether your edge service coordinates a separate provider. If you're comparing providers, an email API integration guide can help you evaluate delivery, templates, and operational controls without putting email logic into the Expo bundle.

Add a minimal health route:

app.get("/api/auth/health", (c) =>
  c.json({ ok: true, service: "auth" })
);

Deploy it, then call the route from a development client or an HTTP tool. A successful response proves the edge binding and routing work before you introduce credentials.

Screenshot from https://example.com/screenshots/expo-hono-supabase-folder-structure.pngScreenshot from https://example.com/screenshots/expo-hono-supabase-folder-structure.png

Implementing Sign-Up with Supabase and Hono

The sign-up request should travel from a typed Expo form to Hono, then from Hono to Supabase Auth. That gives the server one place to validate the payload, reject abusive traffic, apply password policy, and create the matching application profile.

On the client, react-hook-form and zod keep field errors local and predictable:

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(12),
});

type SignUpInput = z.infer<typeof schema>;

async function submitSignUp(input: SignUpInput) {
  const response = await fetch(`${API_URL}/api/auth/signup`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(input),
  });

  if (!response.ok) throw new Error("Unable to create account");

  const auth = (await response.json()) as AuthResponse;
  await SecureStore.setItemAsync("access_token", auth.accessToken);
  await SecureStore.setItemAsync("refresh_token", auth.refreshToken);
  return auth.user;
}

The Hono handler repeats validation. Client validation improves interaction, but it isn't a security boundary.

const signupSchema = z.object({
  email: z.string().email().transform((value) => value.trim().toLowerCase()),
  password: z.string().min(12),
});

app.post("/api/auth/signup", async (c) => {
  const parsed = signupSchema.safeParse(await c.req.json());
  if (!parsed.success) return c.json({ error: "Invalid signup data" }, 400);

  const { data, error } = await supabase.auth.signUp({
    email: parsed.data.email,
    password: parsed.data.password,
  });

  if (error || !data.user || !data.session) {
    return c.json({ error: "Unable to create account" }, 400);
  }

  await admin.from("profiles").insert({
    id: data.user.id,
    email: data.user.email,
  });

  return c.json({
    accessToken: data.session.access_token,
    refreshToken: data.session.refresh_token,
    user: data.user,
  } satisfies AuthResponse);
});

Use a stable contract

FieldTypeNotes
emailstringNormalized before the provider call
passwordstringValidated at both client and edge
accessTokenstringUsed for authenticated API requests
refreshTokenstringStored only through secure device storage
userUserNormalized identity returned by the edge route

The profile insert should be idempotent or protected by a unique key. If email confirmation is enabled, model the unconfirmed state explicitly instead of pretending the account is ready for every protected feature.

The service role belongs in Hono because the device is an untrusted environment.

Handling Sign-In, Sessions, and Token Storage

Sign-in follows the same route shape, but the operational details matter more after the first successful response. Expo posts credentials to /api/auth/signin; Hono calls supabase.auth.signInWithPassword; the client stores the returned token pair and builds its authenticated state from that result.

A diagram illustrating the four-step sign-in lifecycle for mobile apps using Expo, Hono, and Supabase.A diagram illustrating the four-step sign-in lifecycle for mobile apps using Expo, Hono, and Supabase.

A small storage adapter keeps token handling out of screens:

import * as SecureStore from "expo-secure-store";

export const authStorage = {
  getAccessToken: () => SecureStore.getItemAsync("access_token"),
  getRefreshToken: () => SecureStore.getItemAsync("refresh_token"),
  setTokens: async (accessToken: string, refreshToken: string) => {
    await SecureStore.setItemAsync("access_token", accessToken);
    await SecureStore.setItemAsync("refresh_token", refreshToken);
  },
  clear: async () => {
    await SecureStore.deleteItemAsync("access_token");
    await SecureStore.deleteItemAsync("refresh_token");
  },
};

On iOS, SecureStore uses Keychain-backed storage, and Android uses Keystore-backed protection. That doesn't make a compromised device harmless, but it gives sensitive session material a more appropriate custody model than plain application storage.

Rehydrate once at the root

Create an auth provider near the root navigator. On launch, read the stored tokens, test whether the access token is still accepted, and call /api/auth/refresh when it isn't. Replace both stored tokens when refresh succeeds. If refresh fails, clear storage and show the signed-out navigation tree.

Don't leave a second session copy in old AsyncStorage keys after a migration. Stale sessions can produce confusing UI, especially when the app restores a local user while the edge API rejects the associated token.

Your Supabase client should be a singleton configured around the current session, not a new client constructed by every screen. Subscribe to onAuthStateChange or your own provider event so the UI responds to sign-in, refresh, and sign-out without repeatedly resetting navigation.

Hono protected routes should verify the bearer token before they query application data. Validate the signature, issuer, audience, and expiry using Supabase's published key material or the verification mechanism appropriate to your Supabase setup, then attach the verified identity to the Hono context:

app.use("/api/private/*", async (c, next) => {
  const token = c.req.header("authorization")?.replace("Bearer ", "");
  if (!token) return c.json({ error: "Unauthorized" }, 401);

  const user = await verifySupabaseToken(token);
  if (!user) return c.json({ error: "Unauthorized" }, 401);

  c.set("user", user);
  await next();
});

Refresh token rotation means the client must treat the newest refresh token as authoritative. Never continue retrying with an older token after a successful rotation.

For a quick reference flow while testing a separate service, you can also inspect the Underdog io login experience. The important production decision remains yours: keep the mobile client responsible for presentation, while Hono owns session enforcement.

Wiring OAuth and Social Logins into the Flow

Social login adds a provider and a callback, not a separate security architecture. Google and Apple should still end at the same Hono pipeline, where the resulting Supabase identity is checked, the profile is synchronized, and the app receives the session shape used by password sign-in.

Enable the providers in Supabase first. Google needs the appropriate OAuth client configuration, while Apple uses its Services ID and Apple-side credentials. Configure the redirect destinations in Supabase and in Expo's app configuration, then keep the redirect URI identical across development and release builds.

For a native Expo flow, use expo-auth-session and WebBrowser.openAuthSessionAsync. Generate a PKCE verifier, open the provider authorization URL, and listen for the deep-link result. A successful callback should carry an authorization result or provider token to the edge callback route, not directly authorize arbitrary client state.

const result = await WebBrowser.openAuthSessionAsync(
  authorizationUrl,
  redirectUri
);

if (result.type === "success") {
  const callback = await fetch(`${API_URL}/api/auth/oauth/callback`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ url: result.url }),
  });

  const auth = (await callback.json()) as AuthResponse;
  await authStorage.setTokens(auth.accessToken, auth.refreshToken);
}

Validate the callback server-side

Hono should parse the callback, exchange the authorization result through Supabase, and verify that the returned user is the identity expected by the provider. Then upsert profiles using the Supabase user ID and return a normalized response.

Handle dismiss on iOS as a normal cancellation. Don't show a generic server error when a user closes the provider sheet. If Google or Apple is temporarily unavailable, preserve the password or recovery route and return an actionable message instead of leaving the screen in a permanent loading state.

PKCE protects the authorization code exchange, but it doesn't replace redirect validation. Reject unexpected callback origins, state values, or provider responses. Account linking also needs an explicit policy, especially when a user first registers with email and later selects Apple or Google with an address that may be private or differently formatted.

The same patterns are useful when reviewing React Native social login implementation details. Keep provider-specific code at the boundary, and make the rest of the app consume one AuthResponse.

Hardening the Authentication Flow Against Common Risks

A production auth pipeline has to assume that attackers will try every route, not only the polished sign-in screen. Brute force, replay, token theft, account takeover, weak recovery, and legacy exceptions all belong in the threat model.

Start with rate limiting at Hono. Use a sliding-window counter backed by Cloudflare KV, Upstash, or an equivalent edge-safe store. Key limits by a combination of normalized account identifier and request origin, but avoid returning a response that lets attackers enumerate whether an email exists.

app.use("/api/auth/signin", async (c, next) => {
  const email = (await c.req.json()).email?.trim().toLowerCase();
  const key = `signin:${email}:${c.req.header("cf-connecting-ip") ?? "unknown"}`;

  if (await limiter.isBlocked(key)) {
    return c.json({ error: "Try again later" }, 429);
  }

  await next();
});

In real code, parse the request body once and pass the validated result downstream. Apply comparable controls to sign-up, password reset, OTP verification, and recovery attempts. Rate limiting should slow abuse without becoming an easy denial-of-service switch against a legitimate account.

Protect credentials and recovery

Set a clear password policy at the edge, and consider a breached-password check using Have I Been Pwned's k-anonymity approach. Never send the complete password to a third-party lookup service. Supabase's email confirmation and recovery templates should communicate safe next steps without exposing whether an account exists.

MFA belongs on sensitive accounts and high-impact operations. Supabase provides MFA enrollment through supabase.auth.mfa.enroll; your Hono authorization layer should also inspect the authentication assurance context before allowing actions that change billing, ownership, recovery factors, or administrator access.

Security boundary: A valid JWT proves session possession. It doesn't automatically prove permission to modify every row or perform every sensitive action.

Avoid implicit grant flows, long-lived bearer tokens, and AsyncStorage as the primary home for sensitive tokens. Use short-lived access credentials, rotate refresh credentials when the provider supports it, and revoke sessions after password changes, account suspension, or a confirmed compromise.

Log the events you need for investigation, including successful sign-ins, failed attempts, refresh failures, MFA enrollment, recovery requests, provider cancellations, and administrative changes. Store timestamps, user identifiers where known, route names, and coarse client context. Don't log passwords, raw refresh tokens, or authorization codes.

Coverage matters as much as the visible login screen. Security reviews continue to find risk in legacy Resource Owner Password Credentials, conditional-access exceptions, incomplete MFA coverage, CLI paths, older OAuth routes, and recovery endpoints. Map admin, API, refresh, OAuth, and recovery flows before calling the system secure.

For the user-facing recovery experience, keep the reset path consistent with the rest of the pipeline. A focused password reset implementation can help you define the token exchange, deep-link handling, and session invalidation rules without creating a side door around Hono.

Wrapping Up and Next Steps for Your Auth Flow

The finished architecture has a clear path:

  1. Expo collects credentials, opens OAuth sessions, displays validation errors, and stores session material through SecureStore.
  2. Hono exposes one API surface for sign-up, sign-in, refresh, OAuth callbacks, recovery, rate limiting, and authorization middleware.
  3. Supabase Auth verifies passwords and provider identities, issues sessions, and supplies the identity context.
  4. Supabase Postgres stores profiles and application data under RLS and server-side authorization checks.

That separation makes failures diagnosable. A rejected form is a client validation problem. A rejected credential is a provider result. A missing profile is a data synchronization issue. A protected route returning 401 is a session verification problem, while a 403 should represent an authorization decision.

Shippable flow checklist

  • Routes: Sign-up, sign-in, refresh, OAuth callback, logout, and recovery all pass through the Hono API.
  • Secrets: The service-role key exists only in the edge environment.
  • Storage: Access and refresh tokens use SecureStore, and stale legacy storage is cleared during migration.
  • Validation: Hono validates every request independently of Expo form validation.
  • Sessions: The root provider rehydrates state, handles refresh rotation, and reacts to auth events.
  • Authorization: Protected routes verify tokens and check resource ownership or role permissions.
  • Recovery: Lost devices, provider outages, interrupted email delivery, and account takeover scenarios have explicit behavior.
  • MFA: Sensitive accounts and high-impact actions can require stronger assurance.
  • Observability: Auth events support investigation without recording reusable secrets.

Next, enable Supabase email confirmation if your product needs verified addresses. Listen for signed-in events to bootstrap missing profile data safely, record structured events in an auth_events table for product and incident analysis, and add TOTP MFA for accounts that control sensitive data.

AppLighter provides Expo and React Native starter kits with preconfigured authentication, session management, protected routes, and a Supabase adapter alongside a Hono and TypeScript edge-ready API layer. If you want these boundaries wired into a working mobile foundation instead of assembling each piece from scratch, visit AppLighter and use the starter structure as the base for your next auth review.

Stay Updated on the Latest UI Templates and Features

Be the first to know about new React Native UI templates and kits, features, special promotions and exclusive offers by joining our newsletter.