| name | cross-app-shared-auth |
| description | Blueprint for wiring many apps to one shared login plus a central accounts database, so a person has a single account across every app and access to each app accrues per app (opt-in, deny-by-default). Use this whenever onboarding a new app into a shared-auth family, setting up cross-subdomain SSO, building a central entitlement or "app access" database, adding per-app access gating, or when the user mentions one login for many products, shared accounts across apps, subdomain SSO, a central accounts service, app entitlements, or letting people sign up from any app OR a landing page and have it register centrally. Trigger even if the user does not say "shared auth" but describes a portfolio or family of apps that should share identity. |
Cross-App Shared Auth
A repeatable blueprint for connecting a family of apps to one shared identity plus one central accounts database, so:
- a person has a single account across every app (sign in once, known everywhere),
- access to each app is granted per app, on demand, and accrues over time (not all-or-nothing),
- users can sign up from any app OR from a central landing page, and either path registers them centrally.
Use it two ways: to stand up the platform the first time, and to onboard each new app after that. When onboarding, follow the sequence in references/onboarding-checklist.md step by step.
The one idea to hold onto
Identity is shared. Entitlement is per app. These are two independent layers, and conflating them is the mistake that causes almost every problem in this space.
- Identity (who is this person): one shared auth provider issues one stable user id used by every app. Signing in on app A signs you in on app B too (SSO across subdomains). This does not grant access to app B.
- Entitlement (what may this person use): stored in a central accounts database keyed on that shared user id. Each app checks it. No record for this user and this app means no access (deny-by-default).
So "one login" and "you only get into the apps you have actually turned on" are not in tension. They are the intended combination.
Two slots, chosen independently:
| Slot | Job | Examples |
|---|
| Identity provider | issue one shared user id, handle sign in / sign up | Clerk, Supabase Auth, Auth0, Firebase Auth |
| Central accounts store | hold per-app access records, keyed on the shared user id | Convex, Postgres, MySQL, any DB |
The database vendor is free per app. The only hard rule: apps that share one identity must share one identity provider instance, so the user id is common and joins everything.
Read references/architecture.md for the full picture with a diagram.
Prerequisites (have these before onboarding any app)
- One shared identity provider instance for the whole family (not one per app). It should:
- be rooted at the parent domain so its session cookie is shared across subdomains (for example an instance anchored at the apex so
a.example.com and b.example.com share it),
- expose a token/claim format the central DB can verify (many providers call this a JWT template; give it a known name so backends can request it, and make sure it stamps the audience the central DB expects, see guardrail 3).
- A central accounts database and API (the entitlement store). It needs the schema and functions in
references/examples.md: an app_access table (the whitelist), a users table (the identity link), and server-side functions getAccess, activateApp, listMyApps, ensureUser, all keyed on the shared user id and all deny-by-default.
- The central DB trusts the shared identity provider. The central store verifies the provider's tokens (configure its auth with the provider's issuer). This is what makes the same user id resolve inside the central DB.
- A published, versioned platform contract. One short document that pins the platform-specific values every app needs: the template registry (which backends have a JWT template, one per backend), the central store URL, the exact central function names, and the app-slug registry. Without it each app guesses, and invents mismatched APIs against a store that does not have them. Every app cites it by version, so a contract change signals apps to re-check. This is the source of truth the per-app record block points at.
If any of these is missing, build it first. Everything else assumes they exist.
The onboarding sequence (per new app)
This is the heart of the skill. When adding an app to the family, do these in order. The full checklist with commands and gotchas is in references/onboarding-checklist.md.
- Inspect first. Determine the app's framework, whether it already uses the shared identity provider, and whether any accounts wiring already exists. Never assume a clean slate. If the app is not yet on the shared identity provider, that is a dependency: resolve it before the accounts wiring, and do not rebuild it.
- Confirm shared identity. The app uses the shared provider's keys (same publishable key + issuer), not its own instance. For a subdomain family, it pins
authorizedParties to its own origin (see the security note below).
- Add a client to the central accounts DB, separate from the app's own database. Authenticate it by requesting the central store's token template (for example
getToken({ template: "<name>" })), not the bare session token, so the token carries the audience the store checks and the central DB resolves the same user id. Confirm that template, and one for the app's own backend if it differs, exist in the platform's template registry first (guardrail 3); if the app is the first on a new backend, add its template before wiring. Call the central functions by reference, since the app repo does not have the central DB's generated types.
- Wire the gate. Separate the two checks. Unauthenticated requests are always denied at the backend (deny-by-default). For an authenticated user with no row yet, choose one policy and state it: a hub-style "enable this app?" gate, or, for a live app, auto-activate so no one is locked out (guardrail 1). When you auto-activate, call
ensureUser and activateApp("<this-app-slug>") as independent best-effort calls (one failing must not veto the other, guardrail 8), and make the "first authenticated use" guard durable, not an in-memory flag (guardrail 9). Check getAccess("<this-app-slug>") where you gate. Keep the app's own tier, quota, credits, and billing in the app's own database, never in the central store.
- Register the app in the central catalog so it shows up in the portal and anywhere the family is listed.
- Set env vars. Each app ends up with two database URLs: its own, and the shared accounts one. Give them different names so they never collide (see
references/onboarding-checklist.md).
- Deploy and verify. Confirm the central
activateApp fires on sign in and writes a record, and that a signed-out user is gated.
The two sign-up paths (both must register centrally)
A user can join the family from either direction, and both must end up recorded in the central accounts DB under the same shared identity. Support both:
Path A, from the central landing page or portal. The user signs up on the hub, then consciously enables apps (an explicit toggle that calls activateApp). This is discovery: the user opts into apps they have not tried yet.
Path B, from inside an app. The user discovers an app directly (for example lands on app.example.com), signs up there, and the app auto-activates on first authenticated use: it calls ensureUser then activateApp("<slug>"). This populates the central DB with this user and this app enabled, without the user visiting the hub first.
Because identity is shared, both paths create or reuse the same account and the same user id. There is no "app account" separate from a "hub account." The central app_access records simply accrue: one row per app the user has joined, from whichever direction.
Read references/entitlement-model.md for how activation, deny-by-default, tiers, and existing-user migration fit together.
Guardrails (the non-obvious things that break)
These are the failure modes that are easy to miss. Explain them, do not just assert them.
-
Deny-by-default versus existing users. Be precise about which layer "deny-by-default" governs. Unauthenticated requests are always denied at the backend, no exceptions. Authenticated-but-no-row is a policy choice, not a fixed rule: if an app switches this to hard deny while the central DB is empty, every existing user is locked out, because none of them have a record yet. For a live app, use auto-activate on first authenticated use (grandfather + record). Reserve an explicit "enable this app?" gate for the hub, where users opt into apps they have never used. State the chosen policy in the app's record block so a reviewer is not left guessing which layer applies. See references/entitlement-model.md.
-
Shared-cookie security (authorizedParties). When apps share a session cookie across subdomains, each app's backend must verify that a token was minted for its own origin. Otherwise a compromised sibling subdomain can replay the shared session against another app's API. Pin the allowed origin per app. This is non-negotiable for a subdomain family.
-
The token audience, and why the shared session token must stay clean. This is the single most common silent failure, so learn the mechanism rather than memorizing a fix. The central store verifies an incoming token by checking that the token's audience claim (aud) matches the audience the store is configured to accept (in some stores this config field is literally applicationID). The generic session token an identity provider issues has no aud, so it matches no provider and the store rejects it. Typical symptoms: the store returns "no auth provider found matching the given token," calls read as unauthenticated, quota or data is "unavailable," or a portal hangs on "loading" forever.
The fix is a per-service token template, not a global patch. Define a named token template on the shared provider that stamps the audience the central store expects, and have the app request that template when it talks to the store (for example getToken({ template: "<name>" })) instead of sending the bare session token. Then only tokens bound for the central store carry that audience.
Do not solve this by adding the store's audience to the shared session token. That token is every app's generic identity token, so stamping one service's audience onto it makes every app claim "I am for that service," and any other app whose backend validates a different audience then breaks: a Supabase backend expects aud="authenticated", an API gateway JWT authorizer expects its own configured audience, an identity-aware proxy expects its own. One clean session token with no service audience, plus one template per backend, is what keeps a multi-database portfolio correct. The per-service template table is in .
Validate an existing integration (audit mode)
The same model runs in reverse to audit an app that is already wired, not just to build one. Audit against the current guardrail set and the canonical record block, not whatever local copy the app happens to hold: a stale local record block or a lagging contract-version pointer is itself finding number one, because an auditor who trusts it silently skips every newer check. Score each line PASS / FAIL / N-A with file:line evidence.
Grade the evidence; do not treat every check as binary. Many checks depend on runtime state (a live token, a real request) that a repo or CI audit cannot produce, so name the grade you reached:
- PASS (verified): you decoded a real minted token, observed a real accepted authenticated call, or read back a written row.
- PASS-observed (indirect): a side effect proves it (an access row exists, so activation and the token audience both worked), though you did not decode the token yourself.
- NEEDS-RUNTIME (code-only): the code requests the right template or sets the right header, but nothing at rest proves it end to end. This is not a PASS; name the live check that would clear it. For a pre-deploy integration (code complete but not deployed), every runtime-dependent line is NEEDS-RUNTIME by construction; say so plainly rather than reaching for PASS.
Checks:
- Shared identity,
authorizedParties, env names, local-truth: grep for the values and cite where they resolve. Confirm each required env var is set in the deploy environment, not only in .env.example or a local file: a value that lives only in the example file no-ops or throws once deployed.
- Token audience: best evidence is a decoded live token (its
aud equals the store's configured audience, and it carries every claim the central functions read). If you cannot mint one offline, drop to PASS-observed (a written row proves the audience matched) or NEEDS-RUNTIME (code requests the right template). Never infer a PASS from config alone. If the app forwards the token through its own backend, decode it as it arrives at the store (after the hop) and confirm the backend forwards the template token, not the bare session token (guardrail 11).
- Backend template registered, and its cardinality: for every backend the app talks to, classify the audience as fixed (one shared template) or per-resource (one template per resource, for example a proxy whose
aud is the resource id), confirm the template exists in the registry, and confirm the app requests it. A new backend with no template, or a per-resource backend missing a resource's template, is a finding.
- Deny-by-default: make an unauthenticated request against the outermost server boundary the client can reach and confirm it is refused server-side. A proxy may deny before the origin, so note which layer you actually exercised. Then confirm the authenticated-without-row policy matches the record block.
- Activation persisted, two separate questions: (1) did activation ever persist? read back a row (
getAccess) for a known user. (2) does the app ship a standing post-deploy probe that re-checks automatically? A one-time manual read passes (1) but not (2), and a fail-open integration (guardrail 8) needs (2).
- Call isolation: each central call must have its own catch; a single try/catch around sequential awaits is a FAIL, because the first throw skips the rest (guardrail 8).
- Native integration in use: if the app relies on a native provider integration, confirm exactly which claim it adds and that it is a non-audience claim (guardrail 3).
Tag every finding with three fields so it triages correctly: owner (app or platform), severity, and blocks-ship? A platform-owned, already-mitigated issue is not an app blocker, and without the tag it reads like one. Report each with its minimal fix. This mode is also how you re-verify the whole family after any platform-contract change.
Reference files
references/architecture.md: the full architecture with a diagram, the identity-versus-entitlement split, and where each piece of data lives.
references/onboarding-checklist.md: the copy-paste per-app checklist (prerequisites, wiring, env vars, deploy, verify), including a short block each app repo can keep as its own record.
references/entitlement-model.md: activation flow, deny-by-default, the two sign-up paths, existing-user migration, and tiers.
references/examples.md: sanitized, framework-neutral code snippets (accounts schema, the accounts API, the gate and auto-activate, the per-app env checklist).
Adapt every example to the identity provider and databases actually in use. The pattern is the point, not any single vendor.