TL;DR: GitHub login in my Expo app broke three separate times. A deep link that went nowhere. A PKCE flow I wired backwards. A redirect URL with a typo. Each fix is copy-pasteable below. Total auth code is under a hundred lines. Steal it. Auth is the worst part of every app. Nobody downloads your app for the login screen. They tolerate it. Every minute you spend on OAuth is a minute nobody will ever thank you for. I spent three days on it. Here are all three failures, so you spend thirty minutes. The setup: Expo app, Supabase backend, GitHub as the login provider. Users tap "Sign in with GitHub." GitHub approves. Supabase mints a session. The app stores it. Standard stuff. Documented stuff. Stuff that still broke three times. Failure one: the deep link went nowhere. OAuth on mobile works like this. Your app opens a browser. The user approves on GitHub. GitHub redirects to a URL. That URL must reopen your app. That reopening is the deep link. If the link is wrong, the user approves your app and then stares at a browser tab. Approved. Stranded. Confused. That is exactly what happened. Login looked successful on GitHub's side. My app sat there waiting. Forever. The approve button worked. The return trip did not exist. The cause was my redirect URL. In Supabase, you register where OAuth is allowed to return. I registered the web URL of my landing page. On desktop that would be fine. On a phone, the browser does not know your app exists. The session landed on a web page. The app never heard about it. The fix: use your app scheme as the redirect. My scheme is ok2merge://. So the redirect URL becomes ok2merge://auth/callback. Register that exact string in two places. First, in the Supabase dashboard under Authentication, URL Configuration, Redirect URLs. Second, in your GitHub OAuth app settings as the authorization callback URL. Both must match. Character for character. In Expo, declare the scheme in your app config: { "expo": { "scheme": "ok2merge", "ios": { "bundleIdentifier": "app.ok2merge.mobile" }, "android": { "package": "app.ok2merge.mobile" } } } Then handle the return with expo-web-browser and expo-linking. Open the OAuth URL in a session. Wait for the redirect. Dismiss the browser when your scheme fires: import * as WebBrowser from "expo-web-browser"; import * as Linking from "expo-linking"; WebBrowser.maybeCompleteAuthSession(); const redirectTo = Linking.createURL("auth/callback"); const { data } = await supabase.auth.signInWithOAuth({ provider: "github", options: { redirectTo, skipBrowserRedirect: true }, }); if (data?.url) { const result = await WebBrowser.openAuthSessionAsync( data.url, redirectTo ); if (result.type === "success" && result.url) { const params = Linking.parse(result.url); // exchange the code for a session below } } The skipBrowserRedirect: true part matters. You want the OAuth URL back as data, not an automatic redirect. You open it yourself in the auth session. That way you control the return. Failure two: I wired PKCE backwards. Supabase uses PKCE for mobile OAuth. The short version: your app creates a secret verifier. It sends a challenge derived from it. GitHub holds the challenge. When the callback returns a code, your app sends the code plus the original verifier. Supabase checks they match. This proves the app that started the login is the app that finished it. No stolen codes. My bug: I generated the verifier, then threw it away. I treated the flow like web OAuth, where the server holds state. On mobile there is no server holding state. There is just your app. I launched the browser, got the code back, and tried to exchange it with no verifier. Supabase said no. Correctly. The fix is to let the Supabase client own the whole flow. Do not hand-roll the code exchange. When the deep link returns, pull the code param out of the URL and call exchangeCodeForSession: import { makeRedirectUri } from "expo-auth-session"; const params = new URLSearchParams(result.url.split("?")[1] ?? ""); const code = params.get("code"); if (code) { const { error } = await supabase.auth.exchangeCodeForSession(code); if (error) throw error; } One call. The client kept the verifier in storage from signInWithOAuth. It attaches it automatically. My hand-rolled exchange was the entire bug. Deleting code fixed auth. My favorite kind of fix. Lesson: never split an OAuth flow across two libraries. One library starts it, the same library finishes it. The moment two libraries share custody of a code verifier, you will lose it. I lost it. In production. In front of a user. That user was me, but still. Failure three: one dumb redirect URL typo. After fixing one and two, login worked on my phone. Then I built the Android APK. Login broke again. Same code. Same backend. Different platform, different failure. Classic. Android needs the redirect registered in its own way. The scheme works, but Android verifies app links against your package name. I had renamed the package halfway through the project. The config said one thing. The Supabase redirect list said another. Off by a suffix. A typo I introduced myself, weeks earlier, that only detonated on Android. Debugging this took an hour. The fix took ten seconds. Add the exact redirect string to Supabase. Rebuild. Done. Here is my checklist so you skip all three failures: One, use your app scheme as the redirect everywhere. yourapp://auth/callback. In Supabase redirect URLs. In the GitHub OAuth app callback. In your code. All three identical. Two, one library owns the flow. signInWithOAuth starts it. exchangeCodeForSession finishes it. Nothing custom in between except opening the browser. Three, after renaming anything, grep the whole project for the old name. Package names. Bundle IDs. Schemes. Redirect URLs. Renames are where typos hide. They wait for the worst moment. Four, test on a real device early. The iOS simulator and Android emulator handle deep links differently from real phones. My failure one passed in the simulator. It failed on hardware. Simulators lie about links. Phones do not. Here is the full login function, all together, under a hundred lines with imports: import * as WebBrowser from "expo-web-browser"; import * as Linking from "expo-linking"; import { supabase } from "./supabase"; WebBrowser.maybeCompleteAuthSession(); export async function signInWithGitHub() { const redirectTo = Linking.createURL("auth/callback"); const { data, error } = await supabase.auth.signInWithOAuth({ provider: "github", options: { redirectTo, skipBrowserRedirect: true }, }); if (error) throw error; const result = await WebBrowser.openAuthSessionAsync( data.url, redirectTo ); if (result.type !== "success" || !result.url) return null; const code = new URLSearchParams( result.url.split("?")[1] ?? "" ).get("code"); if (!code) throw new Error("No auth code in callback"); const { error: exError } = await supabase.auth.exchangeCodeForSession(code); if (exError) throw exError; return true; } Sign out is one line. Do not forget to test it. Untested sign-out is how you get a user permanently logged in as someone else on a shared device. Ask me how I know. Actually do not. Moving on. await supabase.auth.signOut(); Auth broke three times. Each break taught me the same lesson. Mobile OAuth is a chain of exact strings. Scheme. Redirect. Package. Verifier. One wrong character anywhere breaks everything silently. The fix is always the same: line up every string, let one library hold the secrets, test on hardware. Total auth code: under a hundred lines. Total debugging: three days. Ratio is bad. Hopefully this post fixes your ratio. Next post in the series: the full $0 stack. Vercel, Render, Supabase free tiers. With a table. Everyone loves a table. Follow the ok2merge-build-in-public series — next post Tue/Thu. Star github.com/3ni8ma/ok2merge · Try https://ok2merge.vercel.app