Skip to main content
Three pieces.
  • 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

Panel screens never carry a credential: the session token lives in an 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/*, /v1/contact/*, /v1/jwks/* and /v1/config/* 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.
In the panel, 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 carry environment_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.

One internal event bus

Domain mutations publish to an outbox in the same transaction as the rows they describe, so an event never describes a write that rolled back. A commit nudges the queue to drain it; a periodic sweep is what guarantees it drains when every nudge is lost. See Domain events.