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:
- A role is an authorization boundary inside Sable, enforced by this gateway. It is not cryptographic isolation. There is no key material separating one member from another.
- Org members share one prepaid balance, one usage ledger, and one set of
signed receipts. A
viewercannot spend. Amemberwho mints an API key spends the owner's dollars, and the receipt for that call is visible to everyone who can read the org's usage. - Removing a member or lowering their role takes effect on their very next request — live sessions are updated or deleted, not left to expire.
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.
| Role | Can do |
|---|---|
viewer | Read-only: keys (prefixes), usage, receipts, the member roster. |
member | Viewer, plus mint / rotate / revoke API keys and unfreeze a tripped circuit breaker. |
billing | Member, plus deposits, plan changes, and balance alerts. |
admin | Billing, plus members, invites, and webhooks. |
owner | Everything, including deleting the org. |
Two rules stop the obvious privilege games:
- You can never grant authority above your own. An
admincannot mint anowner. - The owner's own membership row is immutable — an admin cannot demote the person whose balance funds the org.
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}
The session cookie
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
| Variable | Meaning |
|---|---|
SABLE_SESSION_COOKIE_DOMAIN | Domain= attribute. Unset ⇒ a host-only cookie (correct for localhost). Production: .buildsable.com. |
SABLE_SESSION_COOKIE_SECURE | Defaults to true. Set false only for local http dev. |
SABLE_WEBAUTHN_RP_ID | The registrable domain, e.g. buildsable.com. |
SABLE_WEBAUTHN_ORIGINS | Comma-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.
| Event | Fires when |
|---|---|
org_member_added | An invite is accepted and the account joins. |
org_invite_accepted | Same moment, from the invite's point of view. |
org_member_removed | A member is removed, or leaves. |
Endpoints
See the API reference for the full list. In brief:
| Method | Path |
|---|---|
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 |