Guides

Bring your own identity provider

Run your own authorization server — Better Auth, Entra, Auth0, Keycloak — and let this engine verify its tokens and hold your tenants' data.

Most of this documentation assumes the engine provisions an identity-provider realm for your application and creates your users in it. It does not have to.

An external application brings its own authorization server. You own the login page, the users, the passwords, the sessions and the organizations in your own product; the engine verifies your tokens, resolves which tenant they name, and holds the data. Nothing about your identity provider is the engine's to administer, and it never tries.

This page is the whole integration, in the order you hit it.

If the engine creates your realm and your users, you want Self-service onboarding instead. That page assumes a managed application throughout — its credential minting and its invitation flow do not apply here.

What the engine needs to know

Exactly three things, all registered once by a platform operator:

FactWhy the engine cannot work it out
IssuerYour tokens' iss. A managed application's is derived from its realm; yours is on a host the engine does not own.
AudienceYour tokens' aud. What the engine requires before honouring a single claim.
Claim namesWhich claim carries the organization, and which carries the subject. Providers disagree, and guessing wrong resolves the wrong tenant or none.

Plus one more, registered separately because it changes on its own schedule:

FactWhy
Your backend's subjectWhich of your provider's identities is your server, as opposed to one of your users.

That last one is the part integrators are surprised by, so it gets its own section below.

Setting it up

Both steps are a platform operator's, in the console. An application can never register itself.

1. Register the application

Console → Applications → New application → Bring your own.

FieldExample
Issuerhttps://auth.example.com
Audiencehttps://api.deeplinq.example
Signing algorithmsRS256 — see Sign with RS256
Organisation claimorganization
Subject claimsub

The issuer must publish OIDC discovery or RFC 8414 metadata over https; that is where the engine finds your keys. It is fetched once and mirrored, so verification afterwards is offline — do not expect a request to your JWKS on every call.

Claim names are names, exactly as your provider emits them — not paths. A namespaced claim is typed literally: https://auth.example.com/org, with no leading slash and no escaping.

Copy the application id from the application's page. Your backend is configured with it.

There is no realm, and the engine will not invent one. An external application that is asked for a realm is refused rather than given a made-up name.

2. Register your backend's identity

The application's page → Backend identities.

Your authorization server issues your backend a service account. The engine does not create it, does not hold its secret, and never sees it. What it stores is the subject that service account's tokens carry, so it can recognise them.

FieldWhat goes in it
SubjectThe sub your service-account token carries. Exact, case-sensitive.
Client IDFor your records. Never used to authorise anything. Unique within this application; a second application may reuse it.

Find the subject by decoding a real token rather than guessing — providers differ, and some set sub to the client id while others use an opaque identifier:

curl -s "$YOUR_TOKEN_ENDPOINT" \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" \
| jq -r .access_token | cut -d. -f2 | base64 -d | jq '{iss, sub, aud}'

Registering the first backend identity also switches onboarding on for the application. Registering a second one — which is how you rotate — never changes that setting again, so an operator who deliberately suspended onboarding does not have it switched back on by a routine rotation.

Why a subject list exists at all

Because your issuer is not enough on its own.

Every one of your end users holds a token from the same authorization server as your backend. If the engine trusted the issuer alone, any signed-up user could call the onboarding route and create organizations. The subject list is what separates your server from your customers.

This is the shape the industry settled on: Microsoft Entra calls it a federated identity credential and registers (issuer, subject, audience); Google Cloud calls it Workload Identity Federation and maps assertion.sub. Both exist to retire long-lived keys. Here the application registration already pins the issuer and the audience, so only the subject is left to state.

The two kinds of token

Your provider mints both. They differ in one claim, and that claim decides which lane the engine puts them in.

Your backend's tokenclient_credentials, no person behind it:

{
  "iss": "https://auth.example.com",
  "aud": "https://api.deeplinq.example",
  "sub": "<the subject you registered>"
}

It must carry no organization claim. One turns it into a tenant token, which is refused because no tenant granted it anything.

A user's token — whatever flow you already run:

{
  "iss": "https://auth.example.com",
  "aud": "https://api.deeplinq.example",
  "sub": "<the person>",
  "organization": "<their organization in YOUR system>"
}

The organization claim is what resolves the tenant. It must match, byte for byte, the organization_id you sent at signup.

GET /.well-known/oauth-protected-resource advertises the platform's own authorization server, not yours. That is correct — it describes how to reach this engine's operators, and your authorization server is one you already know because you run it. Do not expect to discover your own issuer from the engine.

Building the side that mints them

The engine is a resource server. Everything it does with your tokens is: fetch your metadata once, fetch your JWKS, verify a signature, pin iss and aud, and read two claims. It never calls a token endpoint, never introspects, and never sees a client id.

So if you are building the consuming app rather than plugging in an authorization server you already run, you need far less than the word "OAuth" suggests.

You do not need an authorization server to integrate. A signing key, a JWKS endpoint, a discovery document and a function that puts two claims in a payload is the whole contract. Authorization-code flows, clients, consent screens, PKCE and refresh tokens buy you nothing here — there is no second party for a redirect to protect when your own backend is the caller.

You must publish a discovery document

There is no "JWKS URL" setting. The engine takes your issuer and fetches <issuer>/.well-known/openid-configuration, falling back to RFC 8414's path only on a 404, and reads jwks_uri out of whichever answers.

This is the trap for hand-rolled setups: plenty of JWT libraries will publish a JWKS endpoint and no document pointing at it. A correctly signed token then fails with a key error, which reads as a signing problem rather than a missing file.

The minimum that works:

{
  "issuer": "https://auth.example.com",
  "jwks_uri": "https://auth.example.com/jwks"
}

Serve it at <issuer>/.well-known/openid-configuration over https, from a host the engine can reach.

Sign with RS256

The registration form accepts several algorithms, but the verifier keeps RSA public keys only — a JWKS whose keys are OKP (Ed25519) or EC is read as having no usable key, and every token is refused. If your library defaults to EdDSA, as several do, change it before you mint your first key: rotating later invalidates nothing but is one more moving part during an integration.

Your service token needs iat

iat is not decoration. A backend identity is accepted only for a token minted after the credential's registration cutoff, which is also how a credential is revoked. A token carrying no iat at all can never be after anything, so it is refused — and the refusal is the same resource_owner cause as an unregistered subject.

Libraries that sign an arbitrary payload sign exactly what you hand them. If you build the claim set yourself, put iat in it.

{
  "iss": "https://auth.example.com",
  "aud": "https://api.deeplinq.example",
  "sub": "<the subject you registered>",
  "iat": 1786917632,
  "exp": 1786918532
}

Local development needs a public https issuer

The issuer is fetched, and one that is not absolute https is refused at registration. http://localhost:3000 cannot be registered, so the token lane cannot be exercised on a laptop without giving it a public name.

A named tunnel — Cloudflare Tunnel, ngrok, whatever you already use — pointed at your dev server is enough. Use a stable hostname rather than an ephemeral one: the issuer is stored on the application and is half the key of every organization binding, so a URL that changes per run means re-registering per run.

Call signup once, for a person's first organization

The signup intent is keyed on the person, and it creates that person's organization and binds it. So the call belongs at the moment their first organization is created, naming them, not on every account.

Everyone who joins that organization afterwards enrols on their own first request, because their token carries the same organization claim. Calling signup again for a second member of the same organization asks the engine to bind an organization it has already bound.

The same person's second organization is a different call. Signup binds one tenant per person, so pointing it at a second organization is refused; POST /v1/application/orgs is the route for that, and it is described under A second organization for the same customer.

Where that lands in a typical product:

Your eventCall signup?
A person signs up and gets their own organizationYes, naming them
They create a second, shared organizationNo, use POST /v1/application/orgs
Somebody accepts an invitation to an existing organizationNo — they enrol on first use

Treat it as best effort. It answers 200 on a retry, so a failure is something to retry later rather than something to fail the account creation over — your user signing up is their action, provisioning the tenant is yours.

A worked example: Better Auth

If your product runs Better Auth, the jwt plugin is the whole integration — the OAuth provider plugin is not needed:

jwt({
  jwt: {
    issuer: "https://auth.example.com",
    audience: "https://api.deeplinq.example",
    definePayload: ({ session }) => ({ organization: session.activeOrganizationId }),
  },
  jwks: { keyPairConfig: { alg: "RS256", modulusLength: 2048 } },
})
  • A user token is auth.api.getToken({ headers }) — the session's own, with the organization claim added by definePayload.
  • Your backend's token is auth.api.signJWT({ body: { payload: { sub, iat } } }) — server-only, and org-less by construction. Remember the iat: signJWT signs exactly the payload it is given.
  • Serve the discovery document yourself. The plugin publishes /jwks and nothing naming it.

A person's sub is their account id, which means one rule worth stating out loud: never let a user choose the value that becomes sub. If it were an email or a slug they control, somebody could register an account whose id equals your registered backend subject and mint an application credential from an ordinary signup. Generated ids close that; caller-supplied ones do not.

Onboarding an organization and its first user

One call, from your backend, at signup:

curl -s -X POST "$BASE_URL/v1/onboarding/signups" \
  -H "Authorization: Bearer $YOUR_SERVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "jane@acme.com",
        "subject": "<the person, in your system>",
        "organization_id": "<their organization, in your system>"
      }'
{
  "account_id": "…",
  "personal_org_id": "…",
  "created": true,
  "starter_credit": true,
  "credit_skipped": ""
}

subject and organization_id are required here and absent from the managed flow, for the same reason: the engine did not create the person and did not name the organization, so only you know what either is called. Each is refused by name when missing.

That one call creates the engine account, the organization, the owner's org-admin grant, the model grants and the starter credit, and writes the binding that makes every later token from that organization resolve to this tenant. It answers 200 rather than 201 because it is a convergence: retrying after a lost response is safe and expected.

Store personal_org_id against your own organization record.

The first user needs nothing else

They enrol on their first request. Their token carries the organization claim, the engine resolves the tenant from it, and creates the principal on first sight. There is no user-provisioning call, no sync, and no webhook.

A second organization for the same customer

Signup covers the organization a person is signed up with. It does not cover their second one, and the reason is worth stating because the refusal used to look like a bug.

POST /v1/onboarding/signups binds the person's personal tenant to the organization it was told about. Calling it again for the same person with a different organization_id asks the engine to bind that same tenant to a second remote organization, which the binding table refuses. Every team your customer buys after their first one hit that wall. POST /v1/orgs would make a second tenant, but it refuses an external application outright, because creating an organization at the identity provider is not the engine's to do when you run one.

Both refusals were right. The route that fills the gap between them is:

curl -s -X POST "$BASE_URL/v1/application/orgs" \
  -H "Authorization: Bearer $YOUR_SERVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Acme Engineering",
        "organization_id": "<the organization, in your system>",
        "owner_subject": "<the person who administers it, in your system>"
      }'
{
  "id": "…",
  "name": "Acme Engineering",
  "created": true
}

id is the engine tenant. Store it against your own organization record, the same way you store personal_org_id from signup.

What the three fields are

FieldWhat it is
nameThe tenant's label in Deeplinq. Yours to choose.
organization_idThe organization's id at your own identity provider. The exact value its members' tokens carry in the organization claim.
owner_subjectThe sub of the person who becomes this organization's first org-admin.

The body is closed. An unknown member is 400 rather than silently dropped.

There is no issuer field, deliberately. The issuer is resolved from the application credential that made the call. A body-supplied one would let a caller bind an organization to a trust anchor it chose, which is somebody else's tenant waiting to happen.

What one call actually does

Four things, and only those four:

  • the engine tenant;
  • the identity binding, so every token carrying that organization claim resolves to this tenant;
  • the owner's org-admin in the engine's own role record;
  • the application's default model grants.

The model grants are not decoration. Model access is granted per organization, and a tenant with no grants answers GET /v1/models with an empty list. It works, it is isolated, and it offers nobody a model.

There is no starter credit here, deliberately. That budget onboards a person and is capped application-wide per signup. A team is bought rather than signed up for, and granting the credit per organization would hand the same person a free balance again for every organization they create. Fund the tenant through your own commercial path.

The route creates nothing at your identity provider, sends no mail, and mints no credential. The organization and its membership are yours; the engine records only what it owes.

Retrying is safe

The call is idempotent on the pair (your issuer, organization_id). A repeat answers 200 with the same tenant and created: false, so a retry after a lost response converges rather than creating a second tenant. Two concurrent identical calls converge on the same answer as well.

The tenant and its binding are written in one transaction. A tenant whose binding was lost would be unreachable, because no token resolves to it and nothing repairs it, so a refused create leaves no row behind at all.

When it refuses

StatusCause
400name, organization_id, or owner_subject missing or blank, or an unknown body member. The message names the field.
403The credential is not an application credential holding application-admin.
412The application is managed. The engine administers its realm, so its users create organizations through POST /v1/orgs.
412Onboarding is not enabled for the application. A platform operator enables it in the application's onboarding settings.
409That organization_id is already bound to a tenant belonging to a different application, or to one that is no longer active.

The 409 says nothing about whose tenant it is. The binding proves the remote organization is taken; who took it is not yours to learn.

Where to call it

At the moment your product creates an organization that is not the owner's first:

Your eventWhich call
A person signs up and gets their own organizationPOST /v1/onboarding/signups, naming them
That person creates a second, shared organizationPOST /v1/application/orgs, naming them as owner
Somebody accepts an invitation to an existing organizationNeither. They enrol on first use

What stays yours

An external application keeps the whole membership surface, because the engine has no way to act on your identity provider and will not pretend otherwise:

SurfaceManaged applicationYours
Creating usersEngine, in the realmYou
Password mail, registration pageEngine, via the realmYou
Team organizations (POST /v1/orgs)EngineYou. Refused here; ask for the tenant with POST /v1/application/orgs
Invitations, members (/v1/org/*)Engine, via the realmYou — refused here
SMTP settingsEngine, per realmYou — refused here

Each of those routes answers 412 with a message naming where the surface lives, rather than acting against a realm that does not exist. Create the organization in your own product, then sign its owner up.

Everything below the tenant boundary is unchanged: inference, datasets, conversations, agents, limits, credits and roles all behave exactly as they do for a managed application.

When a call is refused

Every credential refusal reaches you as 401 with the same opaque body, on purpose — a distinguishable message would let a caller probe which applications and subjects are registered. Your operator can see the real cause in the engine's logs, and these are the ones worth knowing:

Logged causeWhat it meansWhat to do
verificationThe signature, issuer, audience or expiry failed.Compare iss and aud byte for byte with what was registered; check the algorithm is one of those declared.
resource_ownerVerified, carries no organization, and no enabled subject matched — because none is registered, because the token predates the registration, or because it carries no iat at all.Check the registered subject against a freshly minted token's sub, and check that token has an iat.
registryVerified, carries an organization, but no binding names it.The organization has not been onboarded — run signup for it.
principalVerified, but the person could not be resolved.The organization resolved; the subject did not.
application-scopeVerified, and the organization resolved, but that organization belongs to a different application than the authorization server that signed the token.An operator re-binds the organization from the right application. See Registering an existing binding.

A token minted before its backend identity was registered is refused, and a cached client_credentials token can easily outlive the registration. If the subject looks right, mint a fresh token before looking anywhere else.

GET /v1/me is the fastest way to see what the engine made of a credential that did authenticate — it reports the lane, the organization and the subject. See Authentication.

Rotating your backend's credential

There is no engine secret to rotate, so rotation is yours:

  1. Create a second service account in your authorization server.
  2. Register its subject as a second backend identity.
  3. Point your backend at the new one and confirm calls succeed.
  4. Retire the old identity in the console.

Two identities may be enabled at once, which is what makes the overlap possible. A third is refused until one is retired.

On this page