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:
| Fact | Why the engine cannot work it out |
|---|---|
| Issuer | Your tokens' iss. A managed application's is derived from its realm; yours is on a host the engine does not own. |
| Audience | Your tokens' aud. What the engine requires before honouring a single claim. |
| Claim names | Which 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:
| Fact | Why |
|---|---|
| Your backend's subject | Which 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.
| Field | Example |
|---|---|
| Issuer | https://auth.example.com |
| Audience | https://api.deeplinq.example |
| Signing algorithms | RS256 — see Sign with RS256 |
| Organisation claim | organization |
| Subject claim | sub |
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.
| Field | What goes in it |
|---|---|
| Subject | The sub your service-account token carries. Exact, case-sensitive. |
| Client ID | For 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 token — client_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 event | Call signup? |
|---|---|
| A person signs up and gets their own organization | Yes, naming them |
| They create a second, shared organization | No, use POST /v1/application/orgs |
| Somebody accepts an invitation to an existing organization | No — 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 bydefinePayload. - Your backend's token is
auth.api.signJWT({ body: { payload: { sub, iat } } })— server-only, and org-less by construction. Remember theiat:signJWTsigns exactly the payload it is given. - Serve the discovery document yourself. The plugin publishes
/jwksand 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
| Field | What it is |
|---|---|
name | The tenant's label in Deeplinq. Yours to choose. |
organization_id | The organization's id at your own identity provider. The exact value its members' tokens carry in the organization claim. |
owner_subject | The 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-adminin 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
| Status | Cause |
|---|---|
400 | name, organization_id, or owner_subject missing or blank, or an unknown body member. The message names the field. |
403 | The credential is not an application credential holding application-admin. |
412 | The application is managed. The engine administers its realm, so its users create organizations through POST /v1/orgs. |
412 | Onboarding is not enabled for the application. A platform operator enables it in the application's onboarding settings. |
409 | That 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 event | Which call |
|---|---|
| A person signs up and gets their own organization | POST /v1/onboarding/signups, naming them |
| That person creates a second, shared organization | POST /v1/application/orgs, naming them as owner |
| Somebody accepts an invitation to an existing organization | Neither. 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:
| Surface | Managed application | Yours |
|---|---|---|
| Creating users | Engine, in the realm | You |
| Password mail, registration page | Engine, via the realm | You |
Team organizations (POST /v1/orgs) | Engine | You. Refused here; ask for the tenant with POST /v1/application/orgs |
Invitations, members (/v1/org/*) | Engine, via the realm | You — refused here |
| SMTP settings | Engine, per realm | You — 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 cause | What it means | What to do |
|---|---|---|
verification | The 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_owner | Verified, 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. |
registry | Verified, carries an organization, but no binding names it. | The organization has not been onboarded — run signup for it. |
principal | Verified, but the person could not be resolved. | The organization resolved; the subject did not. |
application-scope | Verified, 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:
- Create a second service account in your authorization server.
- Register its subject as a second backend identity.
- Point your backend at the new one and confirm calls succeed.
- 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.