Portal
Documentation: all sections

Teams, roles & passkeys

Three things that make Sable usable by more than one person: organizations with roles, passkey sign-in for teammates who do not hold a crypto wallet, and an HttpOnly session cookie so the portal stops keeping a bearer token where any script can read it.


Read this first: what a role protects

An org is a billing entity plus a membership list. It is not a separate account and it does not own anything.

Every resource in Sable — API keys, credit, usage events, receipts, vault assets, hosted agents — belongs to an account. An org designates one of them as its billing account (the owner's), and membership grants you the ability to act as that account under a role.

That means, plainly:

If you need genuine isolation between two workloads, use two accounts, not two roles. Roles bound what a teammate may do; they do not partition the money or the record.


Roles

Ordered least to most privileged. Each role includes everything below it.

RoleCan do
viewerRead-only: keys (prefixes), usage, receipts, the member roster.
memberViewer, plus mint / rotate / revoke API keys and unfreeze a tripped circuit breaker.
billingMember, plus deposits, plan changes, and balance alerts.
adminBilling, plus members, invites, and webhooks.
ownerEverything, including deleting the org.

Two rules stop the obvious privilege games:

An account that has never created an org still behaves exactly as before: its session is owner of its own account, so nothing that worked yesterday is newly refused.


Creating an org and inviting people

# Create. The creator's account becomes the billing account.
curl -X POST https://api.buildsable.com/v1/orgs \
  -H "authorization: Bearer $SABLE_SESSION" \
  -H 'content-type: application/json' \
  -d '{"name":"Acme Research"}'

# Mint an invite link. The token is returned ONCE.
curl -X POST https://api.buildsable.com/v1/orgs/org_.../invites \
  -H "authorization: Bearer $SABLE_SESSION" \
  -H 'content-type: application/json' \
  -d '{"role":"member"}'

The response carries token and a ready-made accept_url. Only a sha256 of the token is stored — the same discipline as sessions, node keys and relay link secrets — so it cannot be re-read later, from the API or from the database.

An invite link is a credential: holding it is the authorization. It expires in 14 days, is single-use, and can be revoked. The email and wallet_address fields are display hints for the roster, not a check — requiring a known wallet address would rebuild exactly the wall this feature exists to remove.

Accepting is session-authed:

curl -X POST https://api.buildsable.com/v1/orgs/invites/$TOKEN/accept \
  -H "authorization: Bearer $SABLE_SESSION"

The invitee must already have a Sable account. Expired, revoked, already-used and simply-wrong links all answer 404 identically, so a scanner learns nothing from the difference.

Acting for an org

curl -X POST https://api.buildsable.com/v1/orgs/org_.../switch \
  -H "authorization: Bearer $SABLE_SESSION"

This mints a new session scoped to the org's billing account, carrying your role. Use that token (or let the cookie carry it) and every management endpoint reads the org's account, bounded by require_role.

In the portal this is the Act as this org button on Team.


Passkeys

Sable's original sign-in is Sign-In With Ethereum. That is right for the wallet-native buyer and a wall for everyone else — an invited teammate with no wallet could not sign in at all.

A passkey (WebAuthn: Face ID, a fingerprint, a hardware key) is a second credential on an account that already exists. Registration is session-authed. There is deliberately no way to create an account from a passkey alone: a wallet — yours, or the teammate's invite accepted after a first wallet sign-in — is always the first credential. That is what keeps this from becoming a second, free account faucet.

Add one from Wallets; sign in with one from the sign-in page. Removing your last passkey is allowed, because your wallet can always still sign in.

What is verified on every ceremony: the clientDataJSON type (a registration response replayed at login is refused), the server-issued challenge (single-use, stored in Postgres so it is multi-replica safe), the origin against an explicit allowlist — this is the whole of WebAuthn's phishing resistance — the RP-ID hash in the authenticator data, the User Present flag, the signature over authenticatorData || sha256(clientDataJSON), and a sign-count regression, which is the cloned-authenticator signal.

Algorithms: ES256 and Ed25519. RS256 is refused rather than silently accepted.

Passkeys are only available where the operator has configured SABLE_WEBAUTHN_RP_ID and SABLE_WEBAUTHN_ORIGINS. When they have not, the routes are not mounted at all and GET /v1/auth/capabilities reports passkey: false, which is what the portal reads before rendering the button.

curl https://api.buildsable.com/v1/auth/capabilities
# {"siwe":true,"passkey":true,"session_cookie":true}

POST /v1/auth/verify (and passkey login, and org switch) now also set an HttpOnly cookie:

Set-Cookie: sable_session=sess_…; HttpOnly; Secure; SameSite=Lax; Domain=.buildsable.com; Path=/; Max-Age=…

The portal no longer needs the token in localStorage, where any injected script could read it. Logout and "sign out everywhere" clear the cookie as well as deleting the row.

Nothing about the header changed. Authorization: Bearer sess_… still works, and always wins over the cookie, so curl, the SDKs and every existing script are unaffected. The token is still returned in the response body.

CSRF

A bearer header is only ever attached deliberately. A cookie is ambient — the browser attaches it to any request the origin permits, including one a malicious page caused. So a cookie-authenticated mutating request (POST/PUT/PATCH/DELETE) must also carry a double-submit token:

X-Sable-CSRF: <value of the sable_csrf cookie>

sable_csrf is set alongside the session and is deliberately not HttpOnly — the client has to read it. It authorizes nothing on its own; it only proves the request came from a page that can read this origin's cookies. A missing or mismatched value answers 403 with type: "csrf_failed".

Requests authenticated with the Authorization header skip this check entirely, and GET/HEAD/OPTIONS never need it.

We chose double-submit over an Origin/Referer allowlist because it fails closed on the cases a header check is weakest at (a stripped or absent Origin, same-site-but-untrusted content) and because it is a pure function of two request values, so it is unit-tested rather than only observable in a browser. SameSite=Lax already blocks the classic cross-site form post; this is the second lock.

Operator notes

VariableMeaning
SABLE_SESSION_COOKIE_DOMAINDomain= attribute. Unset ⇒ a host-only cookie (correct for localhost). Production: .buildsable.com.
SABLE_SESSION_COOKIE_SECUREDefaults to true. Set false only for local http dev.
SABLE_WEBAUTHN_RP_IDThe registrable domain, e.g. buildsable.com.
SABLE_WEBAUTHN_ORIGINSComma-separated exact origins allowed to present an assertion.

Cookie auth needs Access-Control-Allow-Credentials, which the CORS spec forbids alongside a wildcard origin. If SABLE_CORS_ORIGINS is * the gateway keeps working but does not enable credentials, so the cookie is not sent cross-origin — set explicit origins to use it.


Webhook events

Delivered to the org's billing account, because that is the account whose webhooks exist and whose money the new member can now spend. Content-free — ids and roles, never anything about the person.

EventFires when
org_member_addedAn invite is accepted and the account joins.
org_invite_acceptedSame moment, from the invite's point of view.
org_member_removedA member is removed, or leaves.

Endpoints

See the API reference for the full list. In brief:

MethodPath
POST / GET/v1/orgs
GET / PATCH / DELETE/v1/orgs/:id
POST/v1/orgs/:id/switch
GET/v1/orgs/:id/members
PATCH / DELETE/v1/orgs/:id/members/:account_id
POST / GET/v1/orgs/:id/invites
DELETE/v1/orgs/:id/invites/:invite_id
POST/v1/orgs/invites/:token/accept
GET/v1/auth/capabilities (public)
POST/v1/auth/passkey/login/options, /login/verify (public)
POST/v1/auth/passkey/register/options, /register/verify
GET/v1/auth/passkeys
PATCH / DELETE/v1/auth/passkeys/:id