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 / CLI:
- This doc: AppUser auth — end-users of apps deployed on Mortar
- For the Mortar control plane (project owners signing in to manage their workspace via
mortarCLI) — that’s a separate, account-level auth, seearchitecture.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 (mortar CLI 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 — same email can sign up across different
workspaces independently. 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 6-digit OTP + send via comms.sms |
| POST | /v1/{tenant}/auth/phone/verify | API key | consume code, find-or-create AppUser by phone, return JWT |
| POST | /v1/{tenant}/auth/email/verify/start | API key | email a verification token (silently no-ops if email isn’t registered — no enumeration) |
| POST | /v1/{tenant}/auth/email/verify/confirm | API key | consume token, set email_verified=true |
| POST | /v1/{tenant}/auth/password/reset/start | API key | email a password-reset token (silently no-ops if email isn’t registered) |
| POST | /v1/{tenant}/auth/password/reset/confirm | API key | consume token, update password hash |
(Device registration / me endpoints are planned.)
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)
// POST /v1/{tenant}/auth/phone/start
{ "phone": "+8613800138000" }
// 200
{ "sent": true, "expires_in_sec": 300 }
// 429 cooldown / daily_cap_recipient / daily_cap_tenant
// 503 comms_not_configured
// POST /v1/{tenant}/auth/phone/verify
{ "phone": "+8613800138000", "code": "123456" }
// 200 — same {token, user_id, phone, expires_in_sec} shape
// 401 invalid_code / 429 too_many_attempts (attempt cap hit)
Phone-OTP, email verify, password reset — composition
These flows are an auth extension composing two primitives:
comms(comms.smsorcomms.email) delivers the code / token.cachestores the code 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(cap 5 verify attempts per window — past the cap, the code is invalidated so brute force can’t continue).
A send-side throttle sits on every */start endpoint, also via the
cache primitive, distinct 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 overall SMS / email spend).
Blocked requests return 429 with Retry-After. China SMS providers
additionally require a pre-registered SignName + TemplateCode (模板报备);
for the dev local 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 — never reveals whether the email is
registered (no enumeration). Codes are one-time-use: a successful
verify drops both the code and the attempt counter.
Three JWT flavors
Per CONVENTIONS.md and
architecture.md, Mortar runs three distinct token
scopes (matches the three middleware chains):
- Account JWT — Mortar workspace owner login (control plane,
/v1/_accounts/*consumed by themortarCLI). 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}, TTL =MORTAR_JWT_TTL(default 7 days), carriesuid+tid. Layered on top of the project’s API key to scope subsequent data / files / realtime calls to one end-user.
A single Signer handles AppUser JWTs; signing key is per-workspace
(so the workspace can rotate 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}. 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 with customers that have nothing to do with appunvs.