Authentication
The studio has no identity of its own. It mounts the main app's Better Auth handler at its own
/api/auth/*, reads the same session cookie, and mints a short-lived service bearer from the same
secret for every call it makes to apiome-rest and Spire. A sign-in on
main.apiome.dev is therefore a sign-in on
suite.apiome.dev — as long as the two deployments agree on three values.
The pairing rules
| Variable | Rule |
|---|---|
BETTER_AUTH_SECRET | Byte-identical on the main app, the studio and Spire |
BETTER_AUTH_COOKIE_DOMAIN | Identical on the main app and the studio: the parent domain both origins sit under (.apiome.dev). Production only — localhost cannot carry a parent-domain cookie |
BETTER_AUTH_URL | Per origin: each deployment's own public URL |
Breaking the first two is silent — the user simply gets a second login — so the studio's boot checks
log a startup warning naming the variable (missing-secret, host-only-cookie,
origin-outside-cookie-domain). The cookie is __Secure-better-auth.session_token
(Domain=<cookie domain>, SameSite=Lax, Secure, HttpOnly); sessions live in the database
for 30 days, refresh every 24 hours, and are cached in a signed cookie for 60 seconds.
Where sign-in happens
Every guarded page sends a signed-out visitor to /login:
- When
NEXT_PUBLIC_MAIN_APP_URLis another origin — the production layout — the studio's/loginredirects to<main app>/login?callbackUrl=…. The main app's page has every sign-in provider the installation configured, and the session cookie it sets is readable on the studio's origin.callbackUrlis validated before it is followed. - When the main app is the same origin, the studio shows its own form: Sign in to Apiome
Studio — Use your Apiome account credentials. It offers email and password plus the OAuth
providers in the environment (
GITHUB_*,GITLAB_*), and admin-screen provider configuration whenINTERNAL_SERVICE_TOKENis set. An account with two-factor authentication enrolled is told to sign in on the main app.
NEXT_PUBLIC_MAIN_APP_URL must never be the studio's own origin: the redirect would land on the
studio's own guarded /login and loop.
The user-facing version of this is Sign in.
The service bearer
For every server-side call the studio signs a JWT (HS256, one hour) with BETTER_AUTH_SECRET
and sends it to apiome-rest and Spire. The contract is
docs/contracts/suite-auth-token.contract.json (v2):
| Claim | Value |
|---|---|
sub / user_id | The user's id (UUID) |
email, name | From the session |
current_tenant_id | The tenant the session is scoped to |
two_factor_elevated | Whether the account has two-factor authentication enrolled |
Spire verifies against BETTER_AUTH_SECRET, then JWT_SECRET. With no secret configured the
studio sends requests unauthenticated and they are refused with 401; the unsigned-bearer boot
warning says so.
Rotating the secret
BETTER_AUTH_SECRETS rotates the session cookie only. The bearer's secret must change on the
studio and Spire together: keep the old value under JWT_SECRET on Spire during the window, roll
both, then drop it.
Cutover and rollback
The move from NextAuth to Better Auth has no feature flag. apiome-ui at or after 651957d9 pairs
with designer 0.63.0 and Spire 0.21.0 or later; rolling back is a paired redeploy, and the Suite
holds no authentication state of its own. The retired NEXTAUTH_URL, NEXTAUTH_COOKIE_DOMAIN and
NEXTAUTH_SECRET earn a startup warning on the studio — and stop Spire from starting.
Development accounts
The seeded development stack signs in as ada@example.com (password apiome-dev); grace@example.com
is a second seeded member.