auth — Mortar’s feature/auth
End-user authentication for apps deployed onto Mortar Cloud: when a user signs into a todo / chat / e-commerce app that someone built on Mortar (or built on appunvs and published onto Mortar), this is the auth feature handling that flow.
Composed on top of primitive/database. One of Mortar’s 5 features
(auth / data / files / realtime / usage).
Not the same auth as the editor / dashboard:
- This doc: AppUser auth — end-users of apps deployed on Mortar
- For the Mortar dashboard (project owners signing in to manage their workspace) — that’s a separate, account-level auth, see
architecture.md§ Authentication layers.- For Fabric auth (Providers signing in to build apps), see
fabric/docs/auth.md. Fabric’s own user data does not live on Mortar.
Three authentication contexts (don’t conflate)
| Context | Who | Where | Token |
|---|---|---|---|
| Mortar account | Workspace owner (the developer who pays for Mortar Cloud) | mortar_accounts table | Account session JWT (dashboard only) |
| API key | Service-to-service / server-side SDK calls | api_keys table, scoped to a workspace | mtr_live_... long-lived token |
| AppUser ⭐ this doc | End-user of an app deployed on Mortar | app_users table, tenant_id-scoped | Session JWT + Device JWT |
Tables
-- AppUsers: one row per end-user of a published app, scoped to the
-- workspace's tenant_id. email + phone are each unique PER TENANT only
-- WHEN PRESENT — a password user has email + no phone, a phone-OTP user
-- has phone + no email. A plain unique index would collide on the empty
-- string, so uniqueness is enforced via PARTIAL indexes.
CREATE TABLE app_users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL, -- Mortar workspace ID
email TEXT, -- '' when phone-only
phone TEXT, -- '' when email-only
password_hash TEXT, -- '' for phone-OTP users
email_verified BOOLEAN NOT NULL DEFAULT false,
created_at BIGINT NOT NULL
);
CREATE UNIQUE INDEX idx_app_users_ns_email
ON app_users (tenant_id, email) WHERE email <> '';
CREATE UNIQUE INDEX idx_app_users_ns_phone
ON app_users (tenant_id, phone) WHERE phone <> '';
CREATE TABLE app_user_devices (
id TEXT PRIMARY KEY, -- client-generated UUID
user_id TEXT NOT NULL REFERENCES app_users(id),
platform TEXT NOT NULL, -- ios|android|web
created_at BIGINT NOT NULL,
last_seen BIGINT
);
CREATE INDEX idx_app_user_devices_user ON app_user_devices(user_id);
app_users is tenant-scoped — the same email can sign up across different
workspaces independently. The app_ prefix distinguishes from Mortar’s
own mortar_accounts (workspace owners).
Endpoints
All endpoints under /v1/{tenant}/auth/*. Public endpoints
(signup / signin) require an API key in the Authorization header
(or a public-anon key — TBD).
| method | path | auth | purpose |
|---|---|---|---|
| POST | /v1/{tenant}/auth/signup | API key | create AppUser (email+password), return session JWT |
| POST | /v1/{tenant}/auth/signin | API key | verify password, return session JWT |
| POST | /v1/{tenant}/auth/phone/start | API key | cache a 6-digit OTP and deliver it via comms.sms |
| POST | /v1/{tenant}/auth/phone/verify | API key | consume the code, find-or-create the AppUser by phone, return JWT |
| POST | /v1/{tenant}/auth/email/verify/start | API key | email a verification token (silently no-ops if the email isn’t registered — no enumeration) |
| POST | /v1/{tenant}/auth/email/verify/confirm | API key | consume the token, set email_verified=true |
| POST | /v1/{tenant}/auth/password/reset/start | API key | email a reset token (silently no-ops if the email isn’t registered) |
| POST | /v1/{tenant}/auth/password/reset/confirm | API key | consume the token, update the password hash |
(Device registration / me endpoints are planned.)
Phone-OTP, email verify, password reset — composition
These flows are an auth extension composing two primitives:
comms(comms.sms/comms.email) delivers the code or token.cachestores the value with a TTL (5 min for SMS OTP, 15 min for email verify, 30 min for password reset) and runs the attempt counter viacache.IncrTTL. Past the cap (5 attempts per window) the code is invalidated so brute force can’t continue.
A send-side throttle sits on every */start endpoint, also via the
cache primitive — separate from the global HTTP rate limiter:
- per-recipient cooldown: 60 s minimum between sends to the same
email / phone (key
send:cd:{channel}:{tenant}:{recipient}), - per-recipient daily cap: 10 sends / 24 h,
- per-tenant daily cap: 200 sends / 24 h (protects the project’s total SMS / email spend).
Blocked requests return 429 with Retry-After. China SMS providers
additionally require a pre-registered SignName + TemplateCode
(模板报备); for the local dev backend the code lands in
data/comms/<tenant>/outbox.jsonl so the OTP can be read back without
a real provider.
*/start always returns 200 — it never reveals whether the email is
registered. Codes are one-time-use; a successful verify drops both the
code and the attempt counter.
Request / response shapes
// POST /v1/{tenant}/auth/signup
// Authorization: Bearer mtr_live_...
{ "email": "alice@example.com", "password": "hunter2" }
// response 201
{
"token": "<JWT>",
"user_id": "u_...",
"email": "alice@example.com",
"expires_in_sec": 604800
}
// POST /v1/{tenant}/auth/signin → 200 (same response shape as signup)
Three JWT flavors
Per CONVENTIONS.md and
architecture.md, Mortar runs three distinct token
scopes (matching the three middleware chains):
- Account JWT — Mortar workspace owner login (control plane,
/v1/_accounts/*+ Mortar Center dashboard). Out of scope for this doc; seearchitecture.md§ Authentication layers. - API Key (
mtr_live_*/mtr_test_*) — project-scoped credential embedded in mobile / web clients; authenticates every/v1/{tenant}/*call. - AppUser session JWT ⭐ this doc — issued by
/v1/{tenant}/auth/{signup,signin}, with TTL =MORTAR_JWT_TTL(default 7 days), carryinguid+tid. Layered on top of the project’s API key to scope subsequent data / files / realtime calls to a specific end-user.
A single Signer handles AppUser JWTs; the signing key is per-workspace
(so a workspace can rotate it without affecting other tenants).
Password hashing
golang.org/x/crypto/bcrypt with cost 10. Email is lower-cased before
storage and compared case-insensitively.
Persistence
app_users + app_user_devices live in the workspace’s slice of
the shared Postgres pool (see pricing.md § Capacity tiers
for tier-specific row / RAM caps).
SDK surface
All four Mortar SDKs (TypeScript / Dart / Swift / Kotlin) expose this as
mortar.auth.{signUp, signIn}. The AppUser’s session token is then passed
back via mortar.withUserToken(session.token) to scope subsequent
data / files / realtime calls. See each SDK’s README.
Why this is separate from Fabric auth
Fabric’s own Provider users (the humans using appunvs to
build apps) sign into appunvs’s fabric-server, not into Mortar. fabric-server holds
its own users + devices table — feature/auth is decoupled from
appunvs entirely.
Same shape as a full-stack AI builder with a built-in cloud: the cloud’s auth feature handles end-users of the apps its creators publish, while the creators’ own accounts are managed in the builder’s own backend, not on its built-in cloud.
This separation is deliberate: Mortar is a BaaS product whose customers have nothing to do with appunvs.