Auth is the most over-built and most under-tested part of a consumer product. Over-built because founders reach for a login screen on launch. Under-tested because everyone tests "sign up" and "sign in," and nobody tests "I had an account, then I reinstalled."
The concept
Authentication answers who is this? Authorization answers what may they do? Identity is the durable thread connecting a person across devices, sessions and services.
Never build authentication yourself. It is a pure security surface with no product upside, session handling, token rotation, password hashing, breach detection, OAuth flows, account recovery. Every one is a solved problem with well-tested implementations, and every one is a way to leak your users' data.
The signup wall is a product decision
Where you put the account is one of the highest-impact choices in your product:
| Approach | Best for | Cost |
|---|---|---|
| Before anything | B2B, anything with setup cost or shared data | The largest single drop-off in consumer products |
| After first value | Most consumer products | Must handle anonymous → account migration |
| Never required | Utilities, single-device tools | No cross-device, no lifecycle email, no recovery |
For consumer, anonymous-first is usually correct: let people use the product, then ask for the account at a moment where it buys them something. Frame it as a benefit, "save your progress across devices", not an obligation.
INSTALL
└─▶ use the product, get value ← no account, no friction
└─▶ [meaningful action taken]
└─▶ anonymous identity created, data syncs silently
└─▶ later: "save your work" → link a real identity
└─▶ same user id throughout ✅
The critical property: the identity is the same user id before and after. Data, subscriptions and analytics all stay attached. If linking creates a new user, you've built a migration problem instead of an upgrade.
⭐ The auth state matrix
Here is the table that separates working auth from auth that locks out paying customers. Write it down before you code:
| Local session | Identity exists on server | Correct action |
|---|---|---|
| none | no | Sign in → new account |
| none | yes | Sign in → restore |
| anonymous | no | Link → upgrade in place |
| anonymous | yes | ⚠️ Link fails → fall back to sign-in, drop local queue, hydrate |
| real account | yes (same) | No-op |
| real account | yes (different) | Sign out, then in, or refuse |
Row four is the one everybody ships broken. It's not an edge case, it's what happens to every returning user:
1. User installs, uses the app, links their identity → real account
2. User deletes the app, or gets a new phone
3. User reinstalls → no session → a NEW anonymous identity is created
4. User taps "Sign in with Apple"
5. Code calls link(newAnonymous, appleIdentity)
6. ❌ ERROR: that identity is already attached to another account
The user is a legitimate returning customer, possibly a paying one, and your app shows them an error. That error is the expected path, not a failure. Catch it and fall back to signing them into the account that already owns the identity.
And on that path you must also: drop the pending write queue (those writes belong to this device's throwaway session) and hydrate from the server (Chapter 19).
Choosing methods
| Method | Friction | Best for | Watch out |
|---|---|---|---|
| Social (Apple/Google) | Lowest | Consumer mobile | Config is long; Apple required if you offer other social login on iOS |
| Email code (OTP) | Low | Consumer, universal fallback | Deliverability; rate limits |
| Magic link | Low | Web | Fragile returning to a native app |
| Password | Highest | B2B, enterprise expectation | Reset flows, breach exposure |
| SSO / SAML | , | Enterprise | Table stakes above ~$25k ACV |
| Passkeys | Lowest | Increasingly viable | Recovery story still maturing |
In native apps prefer a 6-digit code over a magic link, a link opens a browser, and getting back into the app is fragile. A code never leaves the app.
Apple's rule: if you offer third-party social login on iOS, you must also offer Sign in with Apple.
Platform configuration is longer than the code
The code is an afternoon. The dashboards are a day, and two traps account for most of the lost time:
- OAuth consent screens left in "Testing" mode. Sign-in works perfectly for you (you're a listed test user) and fails silently for everyone else. You will not catch this in development. Check it first when auth "works locally."
- Multiple client IDs for one provider. Native and web clients are different registrations; swapping them yields opaque errors.
Account deletion is mandatory
If your app supports account creation, both major app stores require in-app account deletion, not "email us." It needs elevated privileges (a client can't delete its own auth user), so it's a small server function, and it should cascade to all user data.
Verify by confirming the data is actually gone, not that the button worked.
📐 Best practice
Never build auth. Use a provider.
Make the app usable before any account exists (consumer).
Gate anonymous identity creation behind a meaningful action, so casual opens don't create junk users you're billed for.
Write the state matrix before writing code.
Handle the link → already-exists → sign-in fallback.
Frame the account as a benefit.
Use codes, not magic links, in native apps.
Send yourself the real auth email before shipping. Template bugs are invisible from code and obvious to every user.
Set OAuth consent screens to production before launch.
Ship in-app account deletion and verify the data is gone.
Test on a device that has never seen your app.
💀 Common mistakes
Login wall on first launch (consumer). The largest drop-off available to you.
Not handling the returning-user link failure. Locks out your most valuable users.
Flushing the wrong session's queue after a restore.
Anonymous auth on boot. Junk users, inflated billing metrics, abuse surface.
Consent screen left in Testing. Works for you, fails silently for everyone.
Assuming provider payloads are stable. Apple returns the user's email only on the first authorization; rely on the session user afterwards, or you'll null out stored emails on second sign-in.
Magic links in native apps.
Not testing the real email template. "Sign in123456" is a real failure mode from appending a token to a link.
Provider rate limits at launch. Built-in email senders have low limits; configure real SMTP before you need it.
No account deletion. Store rejection.
Rolling your own session management.
The professional workflow
1. DECIDE WHERE THE ACCOUNT SITS — before / after value / never
2. ⭐ WRITE THE STATE MATRIX — all six rows
3. CHOOSE A PROVIDER (never build it)
4. IMPLEMENT ANONYMOUS-FIRST (consumer)
gate identity creation behind a meaningful action
5. IMPLEMENT LINKING
link → on "already exists" → sign in → DROP QUEUE → HYDRATE
6. CONFIGURE PLATFORMS
app IDs · capabilities · client IDs (native AND web) ·
⚠️ consent screen → PRODUCTION · redirect URIs
7. EMAIL
real SMTP for production limits · SEND YOURSELF THE REAL EMAIL
8. ACCOUNT DELETION — server-side, cascading, verified empty
9. TEST ON A CLEAN DEVICE
new user · returning user · reinstall · wrong account · offline
10. MARK what can only be proven on real hardware (Chapter 18)
Tools, websites & costs
| Need | Tool | Cost |
|---|---|---|
| Auth (BaaS) | Supabase Auth, Firebase Auth | Free tiers |
| Auth (dedicated) | Clerk, Auth0, WorkOS | $0 → $25-99/mo |
| Self-hosted | Keycloak, Ory, Lucia | Free + ops |
| Enterprise SSO | WorkOS, Auth0 | $$$ (priced for enterprise deals) |
| Native social sign-in | Platform SDKs / community plugins | $0 |
| Apple Sign In | Apple Developer Program | $99/yr |
| Transactional email | Resend, Postmark, Brevo | Free tiers |
| Passkeys | Hanko, Passage, provider built-ins | Free tiers |
Alternatives & trade-offs
BaaS auth vs dedicated provider. BaaS is free and integrated with your database's row rules. Dedicated providers (Clerk, WorkOS) give better UI components, organisations, and enterprise SSO, worth it when you sell to companies.
Anonymous-first vs required signup. Anonymous-first maximises activation and adds the linking complexity above. Required signup is simpler and costs you the top of your funnel. Consumer: anonymous-first. B2B: require it, they expect it, and shared data needs identity.
Passwords vs passwordless. Passwordless removes reset flows, breach exposure and support tickets, and depends on email deliverability. Passwords are expected in B2B. Passkeys are the direction of travel; keep a fallback.
Session length. Long sessions are convenient and raise the cost of a stolen device. Short sessions are safer and annoying. Consumer: long, with refresh. Financial or B2B: shorter, with re-auth for sensitive actions.
Multi-tenant identity (B2B): users belong to organisations, invitations must work before the user exists, and roles are per-organisation. Model this from the start, retrofitting organisations onto a single-user model is a large migration.
Checklist
- Using a provider, not custom auth
- App is usable before an account exists (consumer)
- Anonymous identity is gated behind a meaningful action
- The six-row state matrix is written down
- Link → already-exists → sign-in fallback implemented
- Restore path drops the local queue and hydrates
- Account framed as a benefit
- Native uses codes, not magic links
- I have received the real auth email and checked rendering
- OAuth consent screen is in production
- Both native and web client IDs configured correctly
- Production SMTP configured
- In-app account deletion, verified the data is gone
- Tested on a device that has never seen the app
📓 Case Study: the error that is actually the expected path
Project: SOLIS, a consumer app that let users start immediately and asked for an account later.
The model was right. Anonymous identity on first meaningful action, linking to a real identity later, framed as "preserve your ascent on any device" rather than "sign up." The same user id persisted across the upgrade, so progress, subscription and analytics stayed attached.
The anonymous identity was gated behind starting the path, not app launch, so casual opens didn't create billable junk users.
💀 The bug: returning users were shown an error.
The obvious model is a clean two-way split: anonymous users link; new users sign in. Both handled, ship it.
Reality has a third case, and it's the common one:
1. User installs, plays, links Apple → real account exists
2. User deletes the app / gets a new phone
3. Reinstalls → no session → NEW anonymous identity created
4. Taps "Sign in with Apple"
5. link(newAnonymous, appleIdentity)
6. ❌ "that identity is already linked to another account"
A legitimate returning customer, shown an error. From their side: "I paid for this and it won't let me back in."
The fix, and the comment that explains it:
let res = uid ? await sb.auth.linkIdentity({ provider, token: idToken })
: await sb.auth.signInWithIdToken({ provider, token: idToken });
// A returning user (reinstall / new device) is anonymous again, so we try to LINK an
// identity that is already attached to their real account. That is the expected path,
// not an error — fall through to signing them back into the account that owns it.
if (res.error && /already|exists|linked/i.test(res.error.message || "")) {
res = await sb.auth.signInWithIdToken({ provider, token: idToken });
restored = true;
}
"That is the expected path, not an error." The whole lesson is in that sentence.
And restored isn't just a log flag, it changes behaviour downstream. On that path the app must drop the pending write queue (those writes belong to the throwaway session on this fresh install and would pour junk progress into the real account) and hydrate from the server, because this device has nothing local.
Provider payloads aren't stable, and the code documents it:
// Apple returns the email only on the very first authorization; the session user is the
// reliable source on every subsequent sign-in.
const email = (res.data?.user?.email) || prof.email || null;
Relying on the provider payload each time would null out a stored email on the second sign-in.
The configuration traps landed exactly as expected, and the questions in the log are the ones everyone asks: "what is bundle id?" · "in google, support email is coming as dropdown" · "not sure where is that Google OAuth consent screen to In production setting."
That last one is the dangerous one. In "Testing" mode, Google sign-in works perfectly for the developer (a listed test user) and fails silently for everyone else. It was flagged in the handover notes as "the one that silently blocks all non-test users", which is exactly right, and it cannot be caught in development.
Two smaller traps, both instructive:
The email code required editing two provider templates, not one, because linking an email to an anonymous user uses a different template from signing in fresh. Fix one and half your users get the wrong email. Then the first attempt rendered as "Sign in123456", because the token was appended raw after a link. Nobody reads that in code; everybody sees it in an inbox.
A dependency that changed the privacy label. The social-login plugin ships the Facebook SDK by default. It was explicitly disabled in config to keep it out of the binary, "which keeps the Facebook SDK out of the binary (it ships by default and would have forced a much heavier privacy label)." A tracking SDK you don't use still changes what you must declare, and your privacy label is a public promise (Chapter 40).
Account deletion was built and verified properly, a server function verifying the caller, deleting the auth user, cascading to all data, and the verification was the right kind: fresh account created → deleted in-app → database back to zero users, zero rows. Confirming the data is gone, not that the button worked.
⚠️ Deviation: none of it was tested on real hardware. The status file was honest, "Sign in with Apple needs a real device with an Apple ID, so both providers still need a TestFlight pass", and the logic was verified against mocks. The integration was never executed once on a physical device.
Lessons
- Never build auth. Pure security surface, no product upside.
- Anonymous-first removes the largest drop-off in consumer apps.
- ⭐ Write the six-row state matrix first. Most code handles three.
- "Already linked" is the expected path for returning users, not an error.
- The restore path must drop the local queue and hydrate, or you pollute real accounts.
- Provider payloads are not stable across sign-ins. Prefer the session user.
- Consent screens in Testing mode fail silently for everyone but you. Check it first.
- Send yourself the real email. "Sign in123456" is invisible from code.
- Gate anonymous identity behind a meaningful action.
- Adding accounts adds store requirements. Deletion is mandatory, verify the data is actually gone.
- Test on a device that has never seen your app. Your dev device hides every returning-user path.