Appearance
Google OAuth + Sign in with Apple — Supabase social-auth setup
A complete, repeatable, zero-assumed-knowledge walkthrough for wiring Google and Apple sign-in into both Supabase projects (qr-setu-dev, qr-setu-prod). Written for whoever sets this up for the first time — a new team member, or the same person six months from now who has forgotten every field name.
Read this first if you only have five minutes: the app does not use a native Google SDK. Every platform — web, Android, iOS — reaches Google through Supabase's own server-side OAuth redirect (supabase.auth.signInWithOAuth). That single fact is why this setup needs one Google Cloud OAuth client per environment, not three, and no SHA-1 fingerprints anywhere in this document. If you've seen an older plan describing Android/iOS Google clients, that plan was superseded — see "Why only one OAuth client" below before you go looking for a client type this guide doesn't create.
No screenshots in this page, and that's deliberate
This page was written without a browser session against the live Google Cloud Console or Supabase dashboard, so it does not include screenshots of either — a static image of a third-party console goes stale the next time that vendor reskins a settings page, and a screenshot nobody can verify against the live UI is worse than a precise text description. Instead, every step below names the exact menu path, field label, and value to enter. If a menu label in the live console doesn't match what's written here, trust the console (vendor UIs change) and please update this page in the same PR that notices the drift — "if it isn't documented, it isn't done" applies to this doc too.
Who this is for, and what you need before starting
You'll need, before starting:
- Owner/Editor access to both Google Cloud projects:
qr-setu-devandqr-setu-prod. - Owner access to both Supabase projects:
qr-setu-dev(refdyhjofjjuazhyqcvlrkx) andqr-setu-prod(refygmqxyrbnemhwkiyoboc). - For the Apple half: an Apple Developer Program membership (paid, $99/yr) — see "Sign in with Apple" below for exactly which parts are blocked without it and which aren't.
- About 30–45 minutes for Google (both environments), done once. Apple's console setup is a further ~20 minutes, separate from enrollment lead time.
You do not need: any native mobile SDK, an Android keystore/SHA-1 fingerprint, or an iOS provisioning profile — none of those are inputs to this setup. If a step below tries to hand you one of those, stop and re-read "Why only one OAuth client."
Why only one Google OAuth client (not three) — read before you start
The original architecture plan for this feature (ADR-0018 D3) specified the native @react-native-google-signin SDK, which does need separate Web/Android/iOS OAuth clients and three Android SHA-1 fingerprints (debug key, local release key, and Google Play App Signing's own re-signing key). That plan was reversed during implementation (see packages/data/src/auth/oauth.ts's header comment for the full reasoning): the free tier of that SDK ships with zero nonce support, and using it without a nonce would have required disabling a safeguard the same plan called non-negotiable.
The app uses the PKCE authorization-code flow instead, on every surface:
The load-bearing detail: Google only ever talks to Supabase's server. It never sees the app, a device, or a mobile SDK — so the only redirect URI Google's OAuth client needs to know about is Supabase's own /auth/v1/callback endpoint, and that is identical regardless of whether the sign-in started on web, Android, or iOS. Hence: one client, type Web application, per environment.
This is genuinely simpler than the original plan, not a corner cut — it also means there is nothing to reconfigure if a Play Store re-signing key or an app bundle ID ever changes.
This matches Supabase's official documentation — verified, with quotes
This design was challenged during rollout ("we only created a Web client and no Android/iOS clients — is the flow wrong?"), which is a reasonable question because most Google-sign-in tutorials describe the native SDK path. Re-verified against Supabase's own docs rather than argued from memory, and the answer is that the current setup is the documented one for this flow. Do not add Android or iOS OAuth clients while the app uses this flow — they would be inert, and their presence invites exactly this confusion again.
Which client type — /docs/guides/auth/social-login/auth-google, "Project setup":
"Create a new OAuth client ID and choose Web application for the application type… Under Authorized redirect URIs add your Supabase project's callback URL."
When Android/iOS clients ARE required — same page. They belong to the other flow, the one we do not use:
"Unlike the OAuth flow which requires the use of a web browser, the native Sign in with Google flow on Android uses the Credential Manager library… Google issues an identity token… that you then send to your project's Supabase Auth server."
That native path is signInWithIdToken. We call signInWithOAuth + exchangeCodeForSession. The Android SHA-1 fingerprint and iOS bundle-ID client types are inputs to the former only. (Our iOS Apple sign-in does use a native ID-token path — expo-apple-authentication — but that is Apple's console, not Google's, and is unrelated.)
Where the app's deep link is registered — /docs/guides/auth/native-mobile-deep-linking, which documents the exact code shape this app uses (signInWithOAuth with skipBrowserRedirect: true, then WebBrowser.openAuthSessionAsync(data?.url, redirectTo)):
"You can register a scheme in your app config… under the
schemekey… In your project's auth settings add the redirect URL, e.g.com.supabase://**."
So the custom scheme (qrsetu:///sign-in) is registered in Supabase's Redirect URLs allow-list, never in Google Cloud Console. Google never sees it and would reject it if offered — a custom scheme is not a valid redirect URI for a Web-application client. This is Part 3, not Part 1.
Net: per-platform behaviour differs exactly as it should, and it is one code path with one platform-specific value, not three configurations:
| Surface | redirectTo the app sends | How the return leg completes |
|---|---|---|
| Android (installed APK) | qrsetu:///sign-in via Linking.createURL | Chrome Custom Tab closes on the scheme; openAuthSessionAsync returns the URL inline |
| iOS (installed) | qrsetu:///sign-in (same call) | ASWebAuthenticationSession closes on the scheme, same inline return |
| Web / PWA | window.location.origin + pathname | Full-page redirect back; supabase-js's detectSessionInUrl exchanges the code on load |
All three hand Google the same single redirect URI — Supabase's /auth/v1/callback — which is why one Web client per environment is sufficient and correct.
Part 1 — Google Cloud Console
Do this section twice: once for qr-setu-dev, once for qr-setu-prod. The two are independent Google Cloud projects with independent everything (consent screen, credentials) — there is no shared state between them, so skipping the Prod side "for later" is a real gap, not a shortcut.
1.1 — Select the right Google Cloud project
Go to console.cloud.google.com, and use the project switcher at the top of the page (next to the "Google Cloud" logo) to select qr-setu-dev. Double-check the project name shown in that switcher before every step in this section — the single most common way to corrupt this setup is configuring the wrong project's consent screen or credentials because the switcher was left on the other one from a previous session.
1.2 — Configure the OAuth consent screen
Navigate: APIs & Services → OAuth consent screen (left sidebar, or search "OAuth consent screen" in the top search bar).
| Field | Value | Notes |
|---|---|---|
| User Type | External | Merchants are outside your Google Workspace org. |
| App name | QR setu | Same on both projects — this is what the consent screen shows the merchant. |
| User support email | your team's support address | Must be an address you (or a Google group you own) control. |
| App logo | (optional now, required for verification later) | Skip while in Testing mode; add before submitting Prod for verification. |
| Application home page | (optional now) | Needs a real, live URL before verification — see the tracker row this guide creates below. |
| Application privacy policy link | (optional now) | Same — does not exist yet, tracked separately, do not fabricate a placeholder URL here. |
| Application terms of service link | (optional) | Same. |
| Authorized domains | qrsetu.com (once the web app has a real deployed domain) | Leave empty on qr-setu-dev if no domain is live yet. |
| Developer contact information | your team's email | Required, shown to Google only, not to end users. |
Scopes: leave the defaults — openid, .../auth/userinfo.email, .../auth/userinfo.profile. These are non-sensitive scopes; nothing in this app needs Drive, Calendar, or any other sensitive/restricted scope.
Publishing status: leave this at Testing for now, on both projects. Under Test users, add every Google account (yours, teammates') that needs to exercise sign-in before Google verification is done — an account not on this list gets an "Error 403: access_denied" screen, which is the correct behaviour, not a bug.
Verified vs Testing — what actually changes
In Testing, only the accounts you list under "Test users" can complete sign-in, and Google shows no "unverified app" warning to those listed accounts. Once you move to In production (verification), any Google account can sign in, but until Google's review completes, unlisted users may see an "unverified app" interstitial they have to click through. Since this app only requests non-sensitive scopes, Google's own policy does not strictly require full verification review — but the interstitial is bad for conversion on a sign-up screen, so plan to request verification anyway before a public launch. Start it as soon as the logo/homepage/ privacy-policy fields above are fillable — Google's review is not same-day.
1.3 — Create the OAuth client
Navigate: APIs & Services → Credentials → + Create Credentials → OAuth client ID.
| Field | Value |
|---|---|
| Application type | Web application — not Android, not iOS. This is the whole point of the "one client" simplification above. |
| Name | qrsetu-supabase-dev (label only, not user-facing) |
| Authorized JavaScript origins | leave empty — nothing calls Google directly from client-side JS |
| Authorized redirect URIs | exactly one: https://dyhjofjjuazhyqcvlrkx.supabase.co/auth/v1/callback |
Click Create. A dialog shows the Client ID and Client Secret — copy both somewhere safe immediately (a password manager, not a chat message or a plain-text file in the repo). You'll paste them into the Supabase dashboard in Part 3. If you close the dialog without copying the secret, you can regenerate it later from the same Credentials page, but the original secret is never shown again.
1.4 — Repeat for qr-setu-prod
Switch the project switcher to qr-setu-prod and repeat 1.2 and 1.3 verbatim, with two substitutions:
- Redirect URI:
https://ygmqxyrbnemhwkiyoboc.supabase.co/auth/v1/callback(Prod's own callback, not Dev's — pasting Dev's URI into Prod's client is a real mistake that fails silently until the first real sign-in attempt, because the client still saves without error). - Client name label:
qrsetu-supabase-prod(or any label that won't be confused with Dev's in a list view).
You'll now have two Client ID/Secret pairs — one per environment. Do not reuse Dev's pair for Prod, even temporarily; each Google Cloud project's OAuth client is scoped to that project only regardless.
Part 2 — Supabase dashboard: enable the Google provider
Do this once per Supabase project, using that project's own Client ID/Secret pair from Part 1.
- Open the Supabase dashboard, select the project (
qr-setu-devfirst). - Navigate: Authentication → Sign In / Providers (left sidebar → Authentication → Providers tab).
- Find Google in the provider list, toggle it Enabled.
- Paste the Client ID and Client Secret from Part 1.3 (Dev's pair, for the Dev project) into the matching fields.
- Leave "Skip nonce checks" OFF — this app's Google flow is PKCE, which needs no nonce at all, but this toggle also affects the Apple ID-token flow (Part 4), which does need one. Leaving it off is the safe default either way.
- Click Save.
- Repeat for
qr-setu-prod, using Prod's own Client ID/Secret pair from Part 1.4. Do not paste Dev's credentials into Prod's provider settings — same caution as the redirect URI above.
There is nothing else to configure on the Google provider screen — no Android package name, no iOS bundle ID, no SHA-1 field. If your Supabase dashboard version shows those fields, you're looking at a different provider (check you're on the Google row, not a native-mobile-specific one) or Supabase has changed its UI since this page was written — flag it and update this doc.
Part 3 — Redirect URLs allow list (both projects)
Separate from the Google provider's own redirect URI (which points at Supabase, and Google never sees anything else), Supabase needs to know which URLs it's allowed to send the browser/app back to after it finishes its own exchange. This is a generic Auth setting, shared by every provider (Google, Apple, email links).
Navigate: Authentication → URL Configuration → Redirect URLs, and add every value the app can actually request — as literal, paste-ready strings, not a pattern to fill in. This repo has two local web conventions (see apps/mobile/.claude/skills/run-mobile/SKILL.md), so both ports appear below; add whichever you actually use, or both — an unused entry costs nothing.
qr-setu-dev (ref dyhjofjjuazhyqcvlrkx) — paste these lines, one per entry, exactly as written:
http://localhost:8081/sign-in
http://localhost:8081/onboarding/setup
http://localhost:8080/sign-in
http://localhost:8080/onboarding/setup
qrsetu:///sign-in:8081— Metro's live dev server (npm start→ pressw; Metro itself printsWaiting on http://localhost:8081). This is the one that lets you actually click the Google/Apple button and watch the redirect happen — use this set for exercising the flow, not the static preview.:8080— the static preview server for the lastexpo export -p web(npx serve dist -l 8080), the convention this repo uses for eyeballing a build without Metro running. Add it too since it costs nothing, but it serves whatever was last exported, not live edits.qrsetu:///sign-in— the native deep link (Android + iOS dev-client builds pointed at Dev).
qr-setu-prod (ref ygmqxyrbnemhwkiyoboc) — paste this line now; there is nothing else to add yet:
qrsetu:///sign-inNo production web paths exist to give you here, and that's a real gap, not an oversight in this doc. No Cloudflare Pages project exists yet for this app's web export (per CLAUDE.md, no deploy workflow exists at the time of writing). The moment that Pages project is live, add that domain's /sign-in and /onboarding/setup paths to Prod's list — exact paths, per the wildcard warning below, and update this guide's table in the same PR so the next reader isn't left with a stale "nothing to add" line.
The native row is a best-effort transcription of what Linking.createURL('/sign-in') produces given the app's qrsetu scheme, not something confirmed on a running device as of this writing. Before relying on it: add a one-line console.log(Linking.createURL('/sign-in')) during a dev-client launch and register whatever value it actually prints — update this table with the confirmed value in the same PR that does that check, per this project's "verified, never assumed" standard.
Exact paths only in production, never a wildcard
CLAUDE.md and oauth.ts's own comment both state this: a ** wildcard redirect entry turns an open redirect into a token-exfiltration path. List every exact path you need, however many that is — do not add a wildcard to save time.
Part 3.5 — Site URL: a DIFFERENT field, and where the failure path lands
This is not the same setting as Part 3. Same screen (Authentication → URL Configuration), but a separate field above Redirect URLs: Site URL. Every new Supabase project ships with it defaulting to http://localhost:3000 — a placeholder nobody is prompted to change.
What it is actually for, per Supabase's own documentation (/docs/guides/auth/redirect-urls), quoted rather than paraphrased because this field was misdiagnosed once already:
"The Site URL … defines the default redirect URL when no
redirectTois specified in the code. Change this fromhttp://localhost:3000to your production URL… This setting is critical for email confirmations and password resets."
So the scope is narrow and worth stating precisely:
- Every OAuth call this app makes passes an explicit
redirectTo(qrsetu:///sign-inon native viaLinking.createURL,origin + pathnameon web). On the success path Site URL is therefore not consulted at all — the allow-listedredirectTowins. - Site URL is used for email-based flows that carry no
redirectTo(confirmation, magic link, password reset, email change) — which makes it load-bearing for P1's email work, not optional. - Site URL is also where GoTrue sends the browser on some error/fallback paths, when it needs somewhere to go and the requested redirect does not apply.
⚠️ THE MISDIAGNOSIS THIS SECTION EXISTS TO PREVENT — READ BEFORE BLAMING THIS FIELD. A physical-device test showed a Google sign-in landing on http://localhost:3000 with ERR_CONNECTION_REFUSED (on a device, localhost is the device itself, so nothing answers). Site URL was diagnosed as the cause. That was wrong. The real cause was QRS-273 — an invalid Google Client Secret, which made the token exchange fail on every attempt, so every observed redirect was an error redirect and therefore went to Site URL by definition. Changing Site URL relocated where the failure landed (localhost:3000 → qrsetu.com) and fixed nothing, because there was never a successful sign-in to redirect. The generalisable rule: if sign-in is landing on Site URL, the question is not "what is Site URL set to" but "why did the flow take the error path" — pull the auth log and read the actual error first. A constant referer value across every log entry was visible throughout and was read as Site URL leaking through, when the flow-level failure was the thing to look at.
Set it correctly anyway, both projects — it is genuinely needed for the email flows above:
- Authentication → URL Configuration → Site URL on
qr-setu-dev. Set it tohttps://qrsetu.com— a real HTTPS origin. Do not set it toqrsetu:///sign-inor any custom app scheme: it is the global default for all flows including the email links above, and an app-scheme value there would put an unopenable link in every confirmation email a desktop user receives. - Repeat for
qr-setu-prod. - This is a live server-side setting — it applies immediately. No app rebuild is needed, unlike everything else in this guide that gets baked in at build time.
Part 3.6 — Diagnosing a failed sign-in: which error means what
Read the error before changing configuration. Pull the log from Supabase → Logs → Auth (or the MCP get_logs tool, service: "auth") for the current attempt, and match the symptom below. Each row was derived from Google's published error semantics (developers.google.com/identity/protocols/oauth2/web-server) and RFC 6749 §5.2, not from guesswork.
| What the log/browser shows | Where it failed | What it actually means | Fix |
|---|---|---|---|
invalid_client / "The provided client secret is invalid" at /callback, with Unable to exchange external code | Token exchange — the last step, after Google already issued a code | The Client ID + Client Secret pair in Supabase does not authenticate against Google. The client ID, redirect URI and consent all succeeded. Not a client-type, redirect, deep-link or build problem. | Regenerate the secret in Google Cloud → Credentials → the Web client, re-paste into Supabase → Providers → Google. Verify no whitespace, and that the ID and secret come from the same client and the same environment's project. |
redirect_uri_mismatch on Google's own error page (user never returns) | Authorization — the first step | The https://<ref>.supabase.co/auth/v1/callback URL is missing or mistyped in that Google client's Authorized redirect URIs. | Part 1 — add the exact callback URL from Supabase's Google provider page. |
| Supabase error page saying the requested redirect is not allowed | Authorization, at Supabase before Google | The redirectTo the app sent is not in Supabase's Redirect URLs allow-list. | Part 3 — add the exact value (qrsetu:///sign-in, and each web origin+path). |
| Sign-in "succeeds" but the browser lands on Site URL instead of returning to the app | Somewhere upstream took the error path | Site URL is a symptom, not the cause. See the warning in Part 3.5. | Pull the log, find the real error, fix that. |
400: OAuth state parameter missing | /callback reached without a valid flow | Usually a stale/replayed callback URL opened directly, or the provider was not configured when the flow started. | Restart the flow from the app. |
Testing a credential pair directly, without touching Supabase
When the log says invalid_client, the open question is which copy is wrong: the one in Google, or the one pasted into Supabase. Ask Google, rather than guessing or regenerating anything. Send a deliberately malformed code — that isolates client authentication from every later step, because the client must authenticate before the grant is even examined.
PowerShell (the primary shell on the Windows dev box). Note curl.exe, not curl, which is an alias for Invoke-WebRequest and does not accept -d; and note that this must be one line or backtick-continued, because \ is not a line-continuation character in PowerShell (pasting a bash-style multi-line command sends a body-less POST and Google answers 411 Length Required, which looks like a finding and is not one):
powershell
$body = @{
client_id = 'YOUR_CLIENT_ID'
client_secret = 'YOUR_CLIENT_SECRET'
grant_type = 'authorization_code'
code = 'dummy'
redirect_uri = 'https://<project-ref>.supabase.co/auth/v1/callback'
}
try { Invoke-RestMethod -Uri 'https://oauth2.googleapis.com/token' -Method Post -Body $body }
catch { (New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())).ReadToEnd() }The catch is required, not defensive: a 400 is a terminating error in Windows PowerShell 5.1, so without it you see only a generic "400 Bad Request" and never Google's actual JSON, which is the entire point.
| Response | What it proves | Next step |
|---|---|---|
invalid_grant / "Malformed auth code." | The credential pair is VALID. Google authenticated the client and rejected only the fake code. | The value stored in Supabase differs from this one. Compare the Client IDs first, then re-paste the pair. Do not regenerate. |
invalid_client | The pair itself does not authenticate. | ID and secret are from different clients, or Dev/Prod are swapped, or the secret is truncated. |
Do not read invalid_grant as proof the redirect URI is registered — the malformed code short-circuits before Google necessarily validates redirect_uri. That is established separately, and more strongly, by a real attempt's log showing Google issued a genuine code (4/0A…), which it only does after accepting both the client ID and the redirect URI.
⚠️ This command puts a live client secret into shell history. On Windows, clear it with Clear-History and by trimming $env:APPDATA\Microsoft\Windows\PowerShell\PSReadline\ConsoleHost_history.txt.
The load-bearing distinction: invalid_client can only occur at the token endpoint, which is only reached after Google has already validated the client ID and the redirect URI and issued an authorization code. So an invalid_client error is positive proof that the OAuth client type, the registered callback URL, the consent screen and the app's deep-link plumbing are all correct — and that the credentials pair is not. Diagnosing it as anything else costs hours, which is exactly what happened here.
Part 4 — Sign in with Apple
Apple's console works differently from Google's in a way that actually makes the dev/prod split simpler: Apple has no per-environment "project" concept. One Apple Developer account, one Service ID, and one Key cover both Supabase projects — you register both projects' callback URLs on the same Service ID, rather than creating two of everything the way Google's project-scoped Console requires. The steps below build that once, then paste the same four values into both Supabase dashboards.
4.0 — Check this FIRST: can you even reach the pages below, without paying?
Read this before following 4.1–4.6, because it changes what order you can do things in. The previous version of this guide (and the plan it was drawn from) asserted that Apple's Services ID / Keys console pages are reachable on a free Apple ID, with only the final native iOS build gated on paid enrollment. That claim was never actually verified against Apple's live portal — it was carried over from planning, not checked — and Apple has historically restricted the Certificates, Identifiers & Profiles section of developer.apple.com to paying Apple Developer Program members; a free Apple ID typically gets only a "Personal Team" inside Xcode for signing your own local builds, with no web-portal access to create Identifiers or Keys at all.
So, before doing anything else: sign in at developer.apple.com/account and look at the left sidebar.
- You see "Certificates, IDs & Profiles" and it opens to working Identifiers / Keys tabs with a "+" button → good, the paid membership either isn't required for this part or is already active — continue with 4.1 now.
- That section is missing, greyed out, or redirects you to an "Enroll in the Apple Developer Program" page → the $99/yr membership is required before any of the steps below, not only before the final device test. Enroll first (it can take anywhere from minutes to a day or two for Apple to approve), then come back here.
Whichever answer you get, please update this line once you know it for certain — it's written as a check rather than a fact specifically so the next reader doesn't inherit an unverified assumption a second time.
4.1 — Create the App ID (once, covers both environments)
The App ID is the umbrella identity for the app itself — you need exactly one, and everything else (the Services ID, the Key) is configured to point at it.
- Certificates, Identifiers & Profiles → Identifiers (left sidebar).
- Click the + button next to the "Identifiers" heading.
- Select App IDs → Continue.
- Select App (not "App Clip") → Continue.
- Fill in:
- Description:
QR setu - Bundle ID: select Explicit, and enter
in.digious.qrsetu— this is the app's real bundle identifier, taken directly fromapps/mobile/app.json'sios.bundleIdentifier(andandroid.package, which shares the same string). Typing a different value here will silently create an App ID nothing in the app actually uses.
- Description:
- Scroll down to the Capabilities list and check Sign In with Apple. Leave it on its default sub-option ("Enable as a primary App ID") — you're not grouping this under another app.
- Continue → Register.
If an App ID for in.digious.qrsetu already exists (e.g. created earlier for push notifications or another capability), open it instead of creating a new one, and just add the Sign In with Apple capability to it — an app can only have one App ID, and creating a second would fragment capabilities across two identities.
4.2 — Create the Services ID (Apple's equivalent of Google's OAuth client)
- Identifiers → + → select Services IDs this time → Continue.
- Fill in:
- Description:
QR setu Sign In - Identifier: a reverse-domain string that is globally unique across every Apple developer account, not just yours —
in.digious.qrsetu.signinis a safe choice since it's namespaced under the samein.digiousprefix as the real bundle ID. This identifier does not need to match the App ID; it's a separate identity used only for the web/OAuth flow.
- Description:
- Register (this creates the Services ID without Sign In with Apple configured yet — that's the next step, you can't do it in the same form).
- Back on the Identifiers list, click the Services ID you just created to open it again.
- Check the Sign In with Apple checkbox, then click the Configure button that appears next to it.
- A "Web Authentication Configuration" panel opens. Fill in:
- Primary App ID: select the App ID from 4.1 (
in.digious.qrsetu) from the dropdown. - Domains and Subdomains: add both, one per line —
dyhjofjjuazhyqcvlrkx.supabase.co ygmqxyrbnemhwkiyoboc.supabase.co - Return URLs: add both, one per line —These are exactly the same two callback URLs used as Google's redirect URIs in Part 1.3 — Apple's field for "where the provider is allowed to send the user back to" is functionally identical to Google's, just surfaced through a different console.
https://dyhjofjjuazhyqcvlrkx.supabase.co/auth/v1/callback https://ygmqxyrbnemhwkiyoboc.supabase.co/auth/v1/callback
- Primary App ID: select the App ID from 4.1 (
- Click Next, then Done to close the panel, then Continue → Save on the Services ID page itself. (Apple's exact button labels for this sequence have changed slightly across portal redesigns — if what you see doesn't say precisely "Next"/"Done"/"Save", look for the equivalent confirm-then-save pair; the field values above are the part that matters.)
Why one Services ID with two Return URLs, instead of two Services IDs (one per environment): Apple validates the callback URL Supabase actually sends against this list at request time, and the list accepts multiple entries — so a single Services ID legitimately serves both projects. This is fewer moving parts than Google's setup, not a corner cut; if you'd rather mirror Google's one-per-environment shape exactly, create a second Services ID and repeat this section with only Prod's domain/URL, but it buys you nothing extra here.
4.3 — Create the Sign In with Apple Key
This is the credential Supabase uses to verify tokens Apple issues — analogous to Google's Client Secret, but delivered as a downloadable file instead of a string you copy.
- Certificates, Identifiers & Profiles → Keys (left sidebar).
- Click + (or "Register a New Key").
- Key Name:
QR setu Sign In with Apple Key. - Check Sign In with Apple, then click the Configure button next to it → select the Primary App ID from 4.1 (
in.digious.qrsetu) → Save. - Continue → Register.
- A Download button appears exactly once. Click it immediately and save the
.p8file somewhere durable (a password manager or secrets vault, not a repo folder) — Apple will let you view the Key ID again later, but the private key file itself is downloadable only this one time. If you navigate away without downloading it, the only recovery is revoking this key and generating a new one. - Note the Key ID shown on this page — a 10-character alphanumeric string (e.g.
AB12CD34EF).
4.4 — Note your Team ID
Top-right corner of developer.apple.com when signed in (next to your account name), or Account → Membership Details. A separate 10-character alphanumeric string from the Key ID — don't confuse the two when filling in Supabase next.
4.5 — Supabase: enable the Apple provider (both projects, same four values)
You now have everything needed, and — unlike Google — it's the same four values for both projects, because one Services ID already covers both callback URLs.
Authentication → Sign In / Providers → Apple, toggle Enabled, and fill in:
| Field | Value |
|---|---|
| Client ID (Services ID) | in.digious.qrsetu.signin (or whatever you registered in 4.2) |
| Team ID | from 4.4 |
| Key ID | from 4.3 step 7 |
| Private Key | the full contents of the downloaded .p8 file, including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines |
Save on qr-setu-dev, then repeat on qr-setu-prod with the identical four values — there is nothing environment-specific left to change, because the Return URLs list in 4.2 already told Apple both callbacks are valid for this one Services ID.
4.6 — The native entitlement — add this LAST, and only once enrollment is confirmed active
app.json's Expo config plugin for expo-apple-authentication should be added only once the Apple Developer Program membership is confirmed active (the 4.0 check, resolved yes) — adding it before that breaks expo prebuild/expo run:ios under free Personal Team signing, because the plugin requests an entitlement that signing identity cannot sign. Until then, the in-app Apple button correctly falls back to the same browser/OAuth code path Android and web already use (see socialAuth.native.ts's platform branch), so nothing above is wasted by deferring this one step — 4.1 through 4.5 all work today regardless of the entitlement.
Part 5 — Environment variables
Separate from any OAuth-specific config, the app's Supabase client itself needs bootstrapping — this is not new for social auth, but it's the thing that silently breaks the whole feature (including the email-OTP path) if missed:
EXPO_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<the project's publishable/anon key, from Settings → API>Set these per environment in whatever .env mechanism the build uses locally, and confirm they're present in CI before relying on any e2e test that exercises sign-in (see the open follow-up referenced in QRS-270 — this is not yet wired into ci.yml's e2e-web job).
Part 6 — Testing and validation
Do these in order — each one rules out a different layer, so a failure at step 2 after step 1 passed tells you precisely where to look.
- Consent screen renders correctly. From a Test-user Google account (Testing mode) or any account (production mode), start the sign-in flow on web. Confirm the consent screen shows "QR setu" as the app name (not a raw Supabase URL, not "unverified"), and the correct account picker.
- Web round-trip completes. After consenting, confirm the browser lands back on
/sign-in(or/onboarding/setup), the URL's?code=param is gone (stripped bydetectSessionInUrl), and the app proceeds past the auth screen — this proves the redirect URL allow-list entry and the Google client's redirect URI are both correct. - Native round-trip completes (Android + iOS). Same flow from a dev-client build. Confirm the OS-managed auth sheet (
ASWebAuthenticationSessionon iOS, a Chrome Custom Tab on Android) opens, shows the same consent screen, and dismisses back into the app on success — this is the one step that cannot be simulated on web and needs an actual device or emulator/simulator with Google Play Services / network access. - Cancel is silent. Dismiss the consent screen (back gesture / Cancel) on each surface. Confirm the app returns to the idle sign-in screen with no error text — a regression here (showing a spurious error on a deliberate cancel) is the single most common defect in this flow (see
AuthScreen's tests). - Identity linking. Sign up with email OTP at address
X. Sign out. Sign in with Google using an account whose email is alsoX. Confirm you land on the same account (sameprofilesrow, same onboarding state) rather than a second new account — this proves Supabase's automatic identity linking is behaving as expected for this project's configuration. - Apple: name/email capture. On the very first-ever Sign in with Apple for a given Apple ID (this is per-Apple-ID, not per-install — a reinstall does not reset this), confirm the merchant's name is captured into
profiles.full_nameat signup. Every subsequent authorization from that same Apple ID returnsnullfor name/email by Apple's own design, so this specific check only works once per test Apple ID — plan your test account accordingly, don't "test it again" expecting the same result. - Apple: Hide My Email. Repeat sign-in with a new Apple ID, choosing "Hide My Email" at the consent step. Confirm this creates a genuinely separate account (a private-relay address is a distinct email from the merchant's real one) and that onboarding completes cleanly for it.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Google shows Error 403: access_denied for a specific account | Publishing status is Testing and that account isn't in the Test users list | Add the account under OAuth consent screen → Test users, or move to production/verified |
Google shows redirect_uri_mismatch | The OAuth client's Authorized redirect URI doesn't exactly match Supabase's callback (trailing slash, wrong project ref, http vs https) | Re-copy the callback URL from the Supabase dashboard's own display of it, character for character |
| Sign-in "succeeds" on Google's side but the app never resolves the session | The app's redirectTo value isn't in Supabase's Redirect URLs allow list | Add the exact path (Part 3) — a near-match is still a mismatch |
| Works on web, fails on native with a blank/frozen auth sheet | openAuthSessionAsync never observed a redirect because the native redirect target isn't registered in Supabase's allow list | Confirm the printed Linking.createURL('/sign-in') value (Part 3's warning) is the exact string registered |
| A cancelled sign-in shows an error toast | Regression in AuthScreen/useSocialAuth's cancel handling, not a config issue | This is a code bug, not a console misconfiguration — see the auth feature's README and tests |
| Apple sign-in works on web/Android but the iOS button does nothing or crashes | The native entitlement (Part 4.6) was added before enrollment, or is missing after enrollment | Confirm Apple Developer Program is active before adding the config plugin; rebuild after adding it |
| Apple "Invalid client_id" or the Services ID doesn't appear in Supabase's flow at all | The Return URL Apple actually received doesn't match one in the Services ID's list (Part 4.2 step 6), or Dev/Prod's callback wasn't added | Re-check both callback URLs are listed verbatim, including https:// and the exact project ref |
| Can't reach Certificates/Identifiers/Keys at all on developer.apple.com | Free Apple ID without Program enrollment (Part 4.0) | Enroll in the Apple Developer Program before continuing this section |
| Pasted the wrong environment's Client Secret | Dev/Prod credentials swapped during copy-paste | Regenerate the secret for the affected Google Cloud project and re-paste carefully, confirming the project switcher before every copy |
Related
packages/data/src/auth/oauth.ts— the exchange implementation and the full D3-reversal reasoning.apps/mobile/src/lib/socialAuth.{web,native}.ts— the platform-specific credential acquisition.apps/mobile/src/tiers/user/features/auth/README.md— the feature's own parity status and conventions.- ADR-0018 (pending — see P5 in the auth plan; this page can be written before that ADR file exists, but cross-link it once it does).
- ZeptoMail integration guide — the other owner-gated setup this project needs before real OTP email can be delivered; same "lead-time item, not launch-week task" caution applies.
- Dev-tracker: the row this guide's publication adds, for the missing Privacy Policy / Terms of Service / Google-verification production assets.