# Native configuration and requirements matrix | Expo Supabase SaaS Kit

> Look up what each native feature needs configured — across app env, app.json, Supabase, provider consoles and the app stores — and what a change to one setting forces you to change elsewhere.

*Canonical: https://makerkit.dev/docs/react-native-supabase/config-requirements*

---

Native features fan configuration out across many surfaces (app env, `app.json`, Supabase local and hosted, provider consoles, the app stores), and one change frequently *forces* another — the native URL scheme dictates a Supabase redirect-URL entry, for instance. This page is the single place to look up **what a feature needs configured** and **what a config change triggers elsewhere**. It tracks operational reality, not roadmap.

---

## 1. Config surfaces (where config lives)

| Surface | Location | Notes |
|---|---|---|
| **Native build-time env** | `apps/native/.env.development`, `.env.local`, EAS secrets | **`EXPO_PUBLIC_*` only, and they are PUBLIC** — baked into the JS bundle. Never put secrets here. |
| **Native app config** | static `apps/native/app.json` + dynamic `apps/native/app.config.ts` (extends app.json for computed values — currently the conditional Sign-in-with-Apple plugin; later Universal Links) | `scheme`, `ios.bundleIdentifier`, `android.package`, `plugins` (config plugins → native entitlements), `associatedDomains`/`intentFilters` (Universal Links). |
| **Supabase — local** | `apps/web/supabase/config.toml` | `[auth]` `site_url` + `additional_redirect_urls`, `[auth.external.*]`, `[auth.rate_limit]`, `[auth.mfa.*]`, email templates. |
| **Supabase — hosted** | Dashboard → Authentication | **Must mirror `config.toml`.** Hosted is the source of truth in prod; the local stack can't do real Google/Apple OAuth (test OAuth against a hosted dev project). |
| **Google Cloud Console** | OAuth clients | The existing **Web** client (Supabase callback) is reused by the web-browser flow — **no new client needed**. A *native Google SDK* flow (not used) would need iOS/Android clients. |
| **Apple Developer** | App ID + Sign in with Apple capability, **Services ID**, signing **key** | Needed for Sign in with Apple. |
| **App Store Connect / Play Console** | Store listing, privacy, review | Guideline 4.8 (Sign in with Apple), account deletion, data-safety/privacy labels. |

---

## 2. Cross-config coupling / triggers ⚠️ (the important part)

When you change the left column, you **must** also do the right column.

| If you change / add… | …you must also… | Type |
|---|---|---|
| Native `app.json` `scheme` (currently **`mkkit`**) | Update `apps/web/supabase/config.toml` → `additional_redirect_urls` (`<scheme>://**`) **and** the hosted dashboard → Auth → URL Configuration → Redirect URLs. The native OAuth `redirectTo` = `makeRedirectUri({ scheme })`, so the Supabase allow-list is **derived from the native scheme**. | **URL coupling** |
| Enable **any** OAuth provider (Google, etc.) on **iOS** | Offer **Sign in with Apple** (App Store Guideline 4.8). Google-only on iOS risks rejection. | **STORE** |
| Offer **Sign in with Apple** *and* in-app account deletion | Revoke the Apple token on deletion via `/auth/revoke` (Guideline 5.1.1(v)). Needs an Apple **Services ID + .p8 key** + a captured **refresh token** (authcode exchange at sign-in). **Supabase does not do this — the app must.** | **STORE** |
| Add a native module that ships a **config plugin** (e.g. `expo-apple-authentication`) | Add it to `plugins` (via `app.json` or `app.config.ts`), run `npx expo prebuild`, ship a **new dev build** (Expo Go can't load it). | **BUILD** |
| Enable **Sign in with Apple** — add `apple` to `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS` | The `applesignin` entitlement makes **even simulator builds require a paid Apple Developer signing team** (`expo run:ios` → *"No code signing certificates"* without one). Expo **autolinks** `expo-apple-authentication`'s plugin from the dependency, so the entitlement is injected unless `app.config.ts` **strips** it — which it does whenever the flag lacks `apple`. After toggling, do a **clean** prebuild (`rm -rf apps/native/ios` first). **iOS works fine without Apple** (Google + password) — Apple is a store-submission gate (4.8), not functional. | **BUILD / STORE** |
| Enable the **global GoTrue captcha** toggle in Supabase | ❌ **Don't.** It gates sign-in/OTP/reset for *all* clients and breaks token-less native auth (native has no Turnstile token). Rate-limiting is the substitute — but see the ⚠️ note below: it is not yet tightened, so today neither guard is really in place for native sign-up. | **ANTI-TRIGGER** |
| Add a provider to `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS` | Enable that provider in Supabase (local **and** hosted) + ensure a logo asset exists in the native provider-logo registry. | **feature** |
| Change `ios.bundleIdentifier` / `android.package` (`dev.makerkit.app`) | Re-do: Apple App ID, Google/Apple console redirect URIs, Universal-Links `AASA`/`assetlinks.json`, store identity. | **identity** (high blast radius) |
| Add `{{ .Token }}` to an email template | Verify the per-flow template mapping in `config.toml`; the edit is additive (web link + native code coexist) but affects **both** web and native rendering. | **shared-template** |
| Point a native email flow (sign-up / reset) at a **hosted** project | Configure **custom SMTP** in the dashboard first. On the built-in provider, auth emails are capped at a handful per hour *project-wide* and the cap is raisable only via custom SMTP, so the flows can't be exercised. Makerkit's going-to-production guide covers provider choice and the SPF/DKIM/DMARC records. | **BLOCKER** |
| Turn on `EXPO_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX` (mirrors `NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX`) | Point `EXPO_PUBLIC_SITE_URL` at the **deployed web app** — the checkbox's two links open `<site>/terms-of-service` and `/privacy-policy` in a device browser, so a loopback default is dead in a release build. Dev is already handled (the links go through `resolveDevHost`, so the Android emulator rewrite applies); a **physical device** still needs the LAN IP. Both values are build-time inlined, so a new native build is needed. | **feature** (silent failure) |
| Set / change any `NEXT_PUBLIC_PASSWORD_REQUIRE_{SPECIAL_CHARS,NUMBERS,UPPERCASE}` | Mirror it to `EXPO_PUBLIC_PASSWORD_REQUIRE_*` in `apps/native/.env.*` **and ship a new native build** — the value is inlined into the JS bundle, so an env change alone does nothing to installed apps. A mismatch is silent: native accepts passwords web rejects. Both apps validate client-side only (see the note below). | **feature** (silent failure) |
| Bump the **Expo SDK** in `pnpm-workspace.yaml` → `catalogs.expo` | Bump the **`overrides:`** block in the same file in lockstep (currently `expo-constants`, `react-native-svg`). pnpm `overrides` **win over catalog entries**, so a stale entry silently holds a package on the previous SDK while everything else moves — `pnpm install` accepts it, only `expo-doctor` notices. Also: **do not run `npx expo install --fix`** — every native dep is `catalog:expo` (incl. `packages/mobile-ui`'s `peerDependencies`), and `--fix` rewrites them to literal versions, destroying the convention. Hand-edit the catalog from the SDK's `bundledNativeModules.json`, then verify with `expo install --check`. | **BUILD** (silent failure) |

---

## 3. Per-feature config matrix

Legend: ✅ required · ➕ additive/no-behavior-change · — n/a

| Feature | Native env | `app.json` | `config.toml` | Hosted dashboard | Provider console | Store |
|---|---|---|---|---|---|---|
| **Google OAuth** (web-browser flow) | ✅ `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS=google` | — (scheme already set; no plugin) | ➕ `additional_redirect_urls += "mkkit://**"` | ✅ mirror redirect URL; ✅ Google provider enabled | Reuse existing Web client | — |
| **Additional OAuth providers** (Facebook / GitHub / Microsoft…) | ✅ add ids to the comma list | — | — until enabled | ✅ enable each provider | per-provider app (Facebook / GitHub…) | — |
| **Sign in with Apple** (iOS-only) | ✅ add `apple` to the list (filtered out on Android; also gates the entitlement) | ✅ entitlement autolinked from the dep; `app.config.ts` **strips** it unless the flag has `apple` (+ clean prebuild) — needs a paid signing team even on sim | ✅ `[auth.external.apple]` enabled, `client_id = "<bundle id>"` | ✅ Apple provider **Client IDs** = bundle id (no secret for sign-in) | ✅ Apple Developer: App ID + Sign in with Apple capability (paid account) | ✅ Guideline 4.8 (sign-in) |
| **Apple token revocation** (required with Apple + account deletion) | — | — | — | ✅ Apple provider **secret** (Services ID + .p8 key) | ✅ Services ID, Key ID, .p8 signing key, Team ID | ✅ Guideline 5.1.1(v) — `/auth/revoke` on delete (app must call; Supabase doesn't) |
| **Email OTP flows** (magic link / OTP / sign-up / reset) | ✅ `EXPO_PUBLIC_AUTH_MAGIC_LINK` / `_OTP` | — | ✅ **shipped** — additive `{{ .Token }}` panel in `confirm-email.html` + `reset-password.html`; `magic-link.html` planned with passwordless sign-in; **no `[auth.rate_limit]` / `[auth.email]` change** — shared backend policy, see below | ✅ mirror both templates; read the `config push` warning below first | — | — |
| **Password policy** | ✅ `EXPO_PUBLIC_PASSWORD_REQUIRE_{SPECIAL_CHARS,NUMBERS,UPPERCASE}` (all `false`, mirroring web) | — | ➕ optional `[auth] minimum_password_length` / `password_requirements` (both unset) | ➕ mirror if set locally | — | — |
| **Terms/privacy checkbox on sign-up** | ➕ `EXPO_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX` (off, mirroring web) + `EXPO_PUBLIC_SITE_URL` for the two links | — | — | — | — | ➕ often expected at review when the app collects accounts |
| **Universal Links** (not implemented) | ⚠️ planned `EXPO_PUBLIC_ENABLE_UNIVERSAL_LINKS` — **does not exist yet** | ⚠️ none — no `associatedDomains` / `intentFilters` in `app.json` or `app.config.ts` | — | — | Apple Team ID; release SHA-256 | hosted `.well-known/*` |

*(Extend rows as features are added.)*

### Mirroring templates to a hosted project

⚠️ **`config push` is the dangerous one, not the dashboard.** The CLI reads each
`[auth.email.template.*].content_path` off disk and uploads the file body as
`mailer_templates_<flow>_content` — verified in the pinned CLI 2.109.1. So pushing
config for an unrelated reason (a redirect URL, a provider toggle, a rate limit — all
of which the rows above tell you to mirror) ships **every** template in the same
command, while you have honoured a "don't paste into the dashboard" instruction
exactly.

After any mirror, re-check by firing every template against the hosted project.

⚠️ **`reset-password.html` is load-bearing, not cosmetic.** It is the one template that
never interpolates `{{ .RedirectTo }}` — it hard-codes
`{{ .SiteURL }}/auth/confirm?…&type=recovery&next={{ .SiteURL }}/update-password`. Replacing
it with Supabase's stock recovery template therefore drops the user at the site root with a
recovery session instead of on the password form, and **no client can compensate**: native
deliberately sends no `redirectTo` because this template would ignore it, and web's own
`redirectTo` is already dead weight for the same reason. Keep the hard-coded `next`, or
change both the template and every caller together.

### Typed-code preconditions — an operator decision, not something this repo forces

Printing `{{ .Token }}` turns a URL-only secret into a typed 6-digit one, so review
these on the hosted project:

| Setting | Hosted default | Why it matters |
|---|---|---|
| `[auth.email] otp_expiry` | **24 h** | Sizes the brute-force window. 1 h is 24× smaller. Local CLI default is already 3600. |
| `[auth.rate_limit] token_verifications` | 30 / 5 min / IP | The rate half of the bound on a 10⁶ keyspace. |
| `[auth.email] otp_length` | 6 | Native's code entry is a fixed six-box input — an 8-digit code silently cannot be entered. |

They are deliberately left unset in `config.toml`: shared backend policy affecting web
equally, so choosing values is the deploying operator's call. Left at hosted defaults,
the code is a 6-digit secret valid for 24 hours.

### ⚠️ Sign-up abuse — the captcha substitute is not yet in place

Native sign-up deliberately ships **no captcha** (see the anti-trigger row above): the global
GoTrue toggle would break every token-less native auth call, and no native Turnstile SDK
exists. Rate-limiting is the intended substitute — but `config.toml` still carries upstream's
permissive default:

| Setting | Current | Why it matters |
|---|---|---|
| `[auth.rate_limit] email_sent` | **1000 / hour** — upstream's default, untouched by this repo | The one knob bounding sign-up / OTP email flooding. At 1000 it bounds almost nothing. |

No `config.toml` value is changed here, by the same principle as the table above:
`[auth.rate_limit]` is shared backend policy that throttles web's transactional auth email
too, so picking a number is the deploying operator's call rather than something the native
app decides unilaterally. Two consequences worth stating outright:

- **Tighten it before exposing native sign-up publicly.** Anything in the low tens per hour is
  a materially different posture from 1000.
- **It throttles web as well.** Check web's own flows (invitations, password reset, email
  confirmation) still fit under whatever ceiling you pick — a value tuned for native sign-up
  alone can silently starve a burst of team invitations.

### Local template editing — kong bind-mount caveat

The CLI bind-mounts templates into **kong**, which can serve a size-changed file
truncated to its old length — GoTrue then fails with `500 … ends in a non-text
context`, which looks like a syntax error but isn't. After any template edit, restart
**kong and auth** and confirm the served length matches on-disk:

```bash
KONG=supabase_kong_next-supabase-saas-kit-turbo; AUTH=supabase_auth_next-supabase-saas-kit-turbo
docker restart $KONG $AUTH
docker exec $AUTH sh -c "wget -qO- http://$KONG:8088/email/confirmation.html | wc -c"
wc -c apps/web/supabase/templates/confirm-email.html
```

### Password policy — where it is (and isn't) enforced

Both apps validate **client-side only** — sign-up calls `auth.signUp` straight from the
browser or device, with no server action in between — so the zod schema is the whole
gate. GoTrue's own `minimum_password_length` / `password_requirements` are left at their
defaults deliberately: they are shared backend policy, not native config.

⚠️ **The flags only reach the screens that use a policy-derived schema.** Sign-up and the
recovery new-password screen do; the **settings change-password screen does not** — it uses
`PasswordUpdateSchema` from `@kit/accounts/shared`, a flat `min(8)` that ignores the
requirement flags entirely. Web has the same gap on the same screen, so this is upstream's
shape rather than a native regression, and turning the flags on leaves that one screen
accepting passwords the others reject. Setting the requirements **server-side** closes it
for every screen at once, which is the argument for doing so if you enable them at all.

Setting them server-side is safe and needs **no client change** — GoTrue rejects with
`weak_password` + `reasons[]` and both apps render those reasons. One caveat if you do:
`password_requirements` is a 4-level ladder (`""` → `letters_digits` →
`lower_upper_letters_digits` → `lower_upper_letters_digits_symbols`), so the kit's three
independent booleans cannot be mirrored exactly — anything without digits has no server
equivalent.

---

## 4. Store / account requirements (cross-cutting)

| Requirement | Trigger | Notes |
|---|---|---|
| **Sign in with Apple** (App Store 4.8) | Offering Google (or any social) login on iOS | iOS-only, native `signInWithIdToken` + hex-nonce flow. Sign-in config = bundle id in Apple Client IDs (no secret). Required before iOS submission. |
| **Apple token revocation** (App Store 5.1.1(v)) | Sign in with Apple **and** account deletion both present | App must call Apple `/auth/revoke` on delete; needs Services ID + .p8 key + a stored refresh token (authcode exchange at sign-in). Supabase doesn't handle it. |
| **In-app account deletion** (App Store 5.1.1(v) / Play) | Any account creation in-app | Must be reachable in-app; cannot link out to web. |
| **Privacy labels / Play Data Safety** | OAuth collects email / name | Declare on submission; revisit when adding providers. |

---

## 5. Conventions
- `EXPO_PUBLIC_*` are **public** (bundled) — config flags, not secrets.
- Local `config.toml` ↔ hosted dashboard must stay in sync; **hosted wins in prod**.
- OAuth (Google + generic) is **provider-generic** in code — enabling a new provider is config + a logo, not code (except Apple-on-iOS, which is a native sub-flow).
- **Sign in with Apple is OFF by default and build-gated.** Note the Expo gotcha: `expo-apple-authentication` is a dependency (the dormant code imports it), and **Expo autolinks its config plugin at prebuild**, so the `applesignin` entitlement is injected just by the package being installed — not by the `plugins` array. So `app.config.ts` **strips** that entitlement unless `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS` contains `apple` (one flag drives both the button and the entitlement). Toggling requires a **clean** re-prebuild (`rm -rf apps/native/ios` — a merge prebuild keeps the stale entitlement); enabling needs a paid Apple Developer account or the build fails signing. The iOS app is fully usable without Apple (Google + password) — Apple is only required before public App Store submission.
