- The API, at
https://api.userkit.dev— owns the data and every rule. - The panel, at
app.userkit.dev— where your team administers the organization. - Your product — whatever you are building. Talks to the API with an organization API key from your server, and with a publishable key from your pages.
The panel never hands a token to the browser
httpOnly
cookie and the panel’s own server attaches the Authorization header. No XSS can
read that token, and the endpoints that answer with a credential — login,
two-factor, sign-up — write the value straight into the cookie instead of handing
it to script.
Worth copying in your own front end: what the browser never holds, an attack in
the browser cannot steal.
Your product does talk to the API directly
The customer plane is the opposite posture on purpose./v1/boot, /v1/contact-auth/* and /v1/contact/* answer any origin,
because they are called from your domain by design. CORS is not the gate there:
the publishable key’s allowed-origins list
is, and no cookie rides those requests.
Every other path stays on the narrow allowlist.
Authorization is one query
A user belongs to an organization through a membership. The membership carries a role. A role is a set of permissions. The permission catalogue lives in Go — the set of things the system can do is a fact of the codebase. Who holds what lives in the database, so an organization can define its own roles without a deploy. Resolving a session resolves all of it in one round-trip: identity, active organization, role, permissions. The membership JOIN is the authorization, which has two consequences worth internalizing:- revoking a membership invalidates that session immediately, with no cache to wait on;
- an organization code someone pastes into the URL matches no row unless they are a member of it.
The organization travels in the URL (
/{org_code}/…) and in the
X-Organization-Id header. It identifies; it does not authorize. Switching
organizations is navigation, not a mutation.can(session, PERMISSIONS.x) hides what a role cannot do. That is
courtesy. requirePermission in the API is the enforcement.
Environments cut through everything
Every organization owns exactly two: live and test, seeded at creation and not creatable or deletable. Customer-plane tables carryenvironment_id
alongside organization_id.
A machine credential resolves its environment from the stored key row on every
request. There is no environment parameter on that surface, ever — which is what
makes “I pointed staging at production” a thing that cannot happen.
The panel is the exception, and deliberately so: staff are authorized for the
whole organization, so panel reads take ?environment=live|test as an explicit
view parameter.
Capabilities come back on the session
GET /v1/session carries two_factor_available and uploads_available. They
describe the server, not your organization: a client reads them to hide what
the platform cannot do right now, instead of discovering it through a 501 in
the middle of a flow.
GET /health reports the state of the dependencies. Only the database moves the
status code.