Identity & auth
Subspace ships as a single-user tool and grows into a multi-user one without a migration step. One self-hosted server process and one embedded Postgres database serve every user; there is no per-tenant database and no separate auth service. Every request, tRPC call, file fetch, or WebSocket upgrade resolves to a Principal, and everything downstream (page reads, mail, agent runs) acts as that principal.
The Principal
Section titled “The Principal”interface Principal { userId: string | null; deviceId: string; isAdmin: boolean; capabilities: Record<string, boolean>; via: 'session' | 'device' | 'unclaimed' | 'off';}| Field | Meaning |
|---|---|
userId |
The signed-in user, or null for the unclaimed/off fallbacks. |
deviceId |
The originating device row. Every command and event carries it, so provenance is per-browser-session or per-paired-device, never just per-user. |
isAdmin |
true for the admin role. Admins see every user’s task cards, run panels, and device list; they do not bypass page ACLs or comms ownership. |
capabilities |
Flags carried from a device’s pairing code (shell, extensionAutomation, mobilePush, terminal). See device pairing. |
via |
Which resolution path produced this principal. |
Resolution tries each source in order and stops at the first match:
-
Session cookie
The
subspace_sessioncookie resolves againstops.sessions. Producesvia: 'session'. -
Device token
x-subspace-device-token(header) or?token=(WebSocket upgrades, since browsers can’t set custom headers on a WS handshake) resolves againstops.devices. Producesvia: 'device'. -
Unclaimed or off
No cookie, no token: an instance with zero
ops.usersrows returns the legacy full-access principal (via: 'unclaimed');SUBSPACE_AUTH=offreturns the same shape withvia: 'off'regardless of claim state. -
Reject
None of the above:
null. tRPC answers 401; non-tRPC routes throw.
Unclaimed = single-user
Section titled “Unclaimed = single-user”A fresh ~/Subspace install has zero rows in ops.users and is unclaimed. Every
request resolves to a stable dev device id with isAdmin: true and no userId, which is
exactly the pre-auth behavior: no login screen, no ACL friction, full access. This is not
a degraded mode, it’s the default for anyone running Subspace solo. The
localhost/tailnet bind stays the outer network
boundary regardless of claim state.
Claiming
Section titled “Claiming”The first successful login (Google or dev-login) creates that user as admin and
claims the instance in one transaction: it backfills owner_id on every existing page and
file to the new admin, and opens the shared operational surfaces (inbox, task queue,
reminders, calendar, mail, IM, memory) as explicit ACL mode with an everyone: write
grant so nothing that used to be visible to “the one user” becomes invisible to future
members. See page ACLs for the grant model this produces.
From that moment on, anonymous requests get a real 401:
- tRPC: every procedure except
health.pingruns throughauthedProcedure. - Non-tRPC routes carry explicit guards:
/files,/blobs,/mail/proxy-image,/oauth/*. - WebSocket upgrades on
/ws,/pty, and/meetings/audioare rejected before the handshake completes./ptyis admin-only regardless of who else is signed in, matching the trust tiers that put a host shell above ordinary tool access.
Admission after claim is invite-only. An admin calls auth.createInvite (admin-only
tRPC) to mint a one-shot code, optionally locked to a specific email, defaulting to a
7-day expiry. A new member logs in with ?invite=<code> appended to the Google sign-in
URL to redeem it and land as role: member.
Sessions (Google sign-in)
Section titled “Sessions (Google sign-in)”GET /auth/google/start[?invite=<code>] → Google consent (scopes: openid email profile) → GET /auth/google/callback → sets subspace_session cookie/auth/google/start writes a one-shot CSRF state row to ops.oauth_states before
redirecting. The callback verifies that state, then sets an HttpOnly, SameSite=Lax
subspace_session cookie; the server stores only its sha256 in ops.sessions with a
30-day rolling expiry. Google identities key on the Google sub claim
(ops.user_identities): if the verified email already belongs to an existing user, the
new identity links to that user instead of being rejected, so one person can add a second
Google account without creating a duplicate user.
Each web session also mints its own ops.devices row (kind web-session), so view state
(open tabs, panel layout) and command provenance stay scoped per browser tab, not merged
across every browser you’ve ever signed into.
| Endpoint | Auth | Notes |
|---|---|---|
GET /auth/google/start[?invite=] |
open | Begins the OAuth dance. |
GET /auth/google/callback |
open (validates state) | Sets the session cookie. |
POST /auth/logout |
session | Clears the cookie and the ops.sessions row. |
GET /auth/me |
open | Returns {user, mode, devLogin, googleLogin} so the web app can render the right login form without guessing. |
POST /auth/dev-login {email} |
open, fake providers only | Logs in an existing user, or claims the instance if unclaimed. |
Web behavior: any tRPC call that comes back 401 redirects the browser to /login,
which shows a Google button when googleLogin is configured and a dev-login form when
PROVIDERS=fake. The sidebar footer shows the signed-in email with a sign-out control.
Device tokens
Section titled “Device tokens”Non-browser clients (desktop app, mobile app, Chrome extension, and any other paired
device) authenticate with a long-lived bearer token instead of a cookie: the
x-subspace-device-token header on HTTP, or ?token= on WebSocket connects. Tokens are
minted with an sdt_ prefix; the server stores only their sha256 in ops.devices, and
setting ops.devices.revoked is the kill switch, the next request with that token 401s
immediately. See device pairing for how a client gets one.
Each client stores its token differently: desktop reads SUBSPACE_DEVICE_TOKEN from the
environment and stamps it on every renderer and main-process request; the extension keeps
it in chrome.storage.local.deviceToken; mobile keeps it in subspace.deviceToken
(AsyncStorage).
Turning identity off
Section titled “Turning identity off”SUBSPACE_AUTH=off disables identity entirely and restores the pre-multi-user behavior:
every request resolves to the unclaimed principal regardless of what’s in ops.users.
The server refuses to boot with this flag set unless SUBSPACE_HOST is a loopback address
(localhost, 127.0.0.1, ::1), so you can’t accidentally expose an unauthenticated
instance on a tailnet or the open internet.
Admin vs member
Section titled “Admin vs member”Roles are a single column (ops.users.role, admin | member), not a permission list.
Admin adds three things on top of the ordinary member experience: creating invites,
seeing every user’s devices/task cards/run panels in the relevant list views, and
os.exec/code.* dispatch and /pty access. It does not grant blanket page read
access (that’s still governed by page ACLs) and it does not
grant access to another user’s mail, calendar, or IM accounts (see
per-user comms), comms ownership is the one
place admin omniscience stops.