Skip to content

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.

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:

  1. Session cookie

    The subspace_session cookie resolves against ops.sessions. Produces via: 'session'.

  2. Device token

    x-subspace-device-token (header) or ?token= (WebSocket upgrades, since browsers can’t set custom headers on a WS handshake) resolves against ops.devices. Produces via: 'device'.

  3. Unclaimed or off

    No cookie, no token: an instance with zero ops.users rows returns the legacy full-access principal (via: 'unclaimed'); SUBSPACE_AUTH=off returns the same shape with via: 'off' regardless of claim state.

  4. Reject

    None of the above: null. tRPC answers 401; non-tRPC routes throw.

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.

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.ping runs through authedProcedure.
  • Non-tRPC routes carry explicit guards: /files, /blobs, /mail/proxy-image, /oauth/*.
  • WebSocket upgrades on /ws, /pty, and /meetings/audio are rejected before the handshake completes. /pty is 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.

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.

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).

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.

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.