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/* 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.
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. GET /health reports the state of the dependencies. Only the database moves the status code.

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.