Skip to content

How Kleora is put together

The shape of the hosted service — one address per environment, two API surfaces, and where state lives. And why self-hosting is not something we support today.

One address per environment

Every environment of every App has its own issuer hostname: https://acme.kleora.eu for production, https://acme.sandbox.kleora.eu for sandbox. That is not cosmetic. An OpenID Connect issuer is identified by a URL, so giving each environment its own means discovery, key sets, tokens and sessions are separated by the protocol itself rather than by a check somewhere in our code.

A request arriving on one of those hostnames is resolved to exactly one environment before anything is routed. An unknown host is a 404, never a fallback to somewhere else.

The management API answers on a different host — api.kleora.io — and only there.

Two surfaces, and what each is allowed to be

SurfaceWhere it answersWho may call it
OAuth 2.1 / OpenID Connect/oauth/, /.well-known/ on an issuer hostAnyone. It is the surface that issues credentials.
The hosted sign-in pageseverything else on an issuer hostYour users, in a browser.
The management API/api/v1/ on the management hostAuthenticated principals, scoped to one environment or account.

The split is enforced by the host the request arrived on: the management API does not exist on an issuer hostname, and the OAuth surface does not exist on the management host. Not "is refused" — the routes are not mounted, so the answer is a 404 by construction. The two also live in separate packages in the source, so the question "is this reachable without credentials?" always has a deliberate answer rather than an accidental one.

In front of both sits an edge proxy. For an issuer host it decides by path: the OAuth and flow endpoints go to the application, and everything else is served from the sign-in bundle. It forwards the Host header untouched, which is what lets one deployment answer for every customer's issuer without a list of them anywhere.

The sign-in pages are a static application

The pages your users see — sign in, sign up, verify email, reset password, choose a workspace — are a static single-page application served on your App's own issuer host. It talks only to /flow/* on that same origin, which is a JSON API; no HTML is rendered by the server at all.

Two consequences worth knowing. Your branding is data, not a template: the page fetches the branding record as JSON and applies it, which is why a colour change takes effect without a deploy on either side. And sign-in requires JavaScript.

Completion of a flow is always a top-level navigation back to /oauth/authorize/continue, so an authorization code is only ever delivered in a navigation response — never in a JSON body a script could read.

Where state lives

StoreWhat it holds
PostgreSQLThe system of record. Accounts, Apps, environments, users, workspaces, memberships, roles, clients, keys and the audit log.
RedisCache, rate-limit counters and background-job results — everything that can be rebuilt or expired.
RabbitMQThe queue for work that must not be lost between being scheduled and being done: email delivery above all.

Every row that belongs to an environment carries that environment's identity, and the application's data access is scoped to one environment by construction — a query with no environment is a programming error that fails rather than returning somebody else's rows.

Access tokens are stateless: they are signed JWTs, and nothing is looked up to verify one. Refresh tokens, sessions and authorization codes are the opposite — rows in PostgreSQL, so revocation is immediate for them.

What that means for your application

  • Verification costs you no request to us. Your API fetches the key set once, caches it for as long as the response says, and verifies signatures locally. Roles and permissions are in the token.
  • Key rotation does not need a coordinated deploy. The next signing key is published in the key set before it is ever used, so the kid of a freshly signed token is already in your cache.
  • The two surfaces are not isolated from each other yet. They are one deployable today, so they cannot be scaled or rate-limited independently. The code boundary is real and the deployment boundary is the future work; we would rather say that than imply an isolation you are not getting.

The contracts you can build against

  • {issuer}/.well-known/openid-configuration — discovery, and from it the key set. Everything a generic OpenID Connect client needs; ours are a convenience, not a requirement.
  • {issuer}/oauth/openapi.json — the OpenAPI document for the OAuth surface.
  • https://api.kleora.io/api/v1/openapi.json — the same for the management API, and the document our own clients are generated from.

Authorization code with PKCE is the browser flow; implicit and password grants are not offered.

Why there is no self-host story

Three things would have to be true, and none of them is yet. There is no deployment boundary between the two API surfaces, so "run the public half in your DMZ" is not a configuration — it is a change to the architecture. There are no published images, and nothing versioned to run. And there is no supported configuration: the settings that exist assume our own operational context, and we have never run the thing anywhere else.

We would rather publish this page than a deployment guide that quietly does not work. If that changes it will be because there is something real to run, and this page is where it will be said.