Authentication
Authenticate with OAuth2 tokens from the platform identity provider — the only tenant authentication method.
Deeplinq has one tenant authentication method: OAuth2 access tokens, which the engine verifies offline against the issuer's JWKS, so there is no call to the provider on the request path.
There are two lanes, and which one you are on follows from the credential, never from a header.
Which issuer is a property of your application, not of the deployment. An application this engine administers takes the issuer of the realm it created; an application that brings its own authorization server declares one, and the engine verifies against that instead — same lanes, same rules. See Bring your own identity provider.
A third lane exists for an application's own backend, holding a credential scoped to that application and no tenant. It is the only thing that may call the self-service onboarding route, and it reaches nothing else.
Read Identity model first if you have not. Which application (realm) and which organization a token names decides almost everything below, including most refusals.
The machine lane — your backend
Your organization is provisioned with a machine credential at the identity provider. Your backend exchanges it for an access token and presents that:
Authorization: Bearer <machine access token>Obtain the token with the standard client-credentials grant:
curl -s "$ISSUER/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d client_id="$DEEPLINQ_CLIENT_ID" \
-d client_secret="$DEEPLINQ_CLIENT_SECRET"$ISSUER is your application's realm — https://identity.example.com/realms/deeplinq
— and you can read it, along with the scopes to request, from the engine rather
than hard-coding either:
curl -s "$BASE_URL/.well-known/oauth-protected-resource"A machine credential needs no organization scope: the client carries a mapper that pins its organization into every token it mints, so the claim is there whether or not anyone asks.
A machine token carries no end user. It reaches inference and
organization-governance endpoints. Every user-owned endpoint — conversations,
connections, agent runs, memory — refuses it with a 400, because there is no
person to own the row. No header changes that:
{ "error": { "type": "invalid_request", "field": "authorization",
"message": "this route is user-owned and the credential carries no end user…" } }That refusal is the design working, not a misconfiguration.
What a machine token reaches, verified against the router:
| Surface | Machine token gets |
|---|---|
/v1/chat/completions, /v1/messages, /v1/models, /v1/usage, /v1/datasets/* | Normal response — no end user needed (ordinary ACL/entitlement rules still apply per call) |
/v1/limits | 403 org-admin required — reachable route, role-gated |
/v1/guardrails, /v1/org/connectors | 403 org-admin required — same shape as /v1/limits |
/v1/connectors (platform connector registry) | 403 platform-admin required — a tenant credential, machine or not, never carries platform authority |
/v1/me/usage, /v1/readiness, /v1/model-grants, /v1/mcp-servers, /v1/webhook-endpoints, /v1/webhook-events, and every other route not listed above | 400 invalid_request naming authorization — refused before any role is even checked, because the route is user-owned |
The first two rows are one boundary — route reachability — decided once,
in the router, by an explicit allowlist
(orgLevelPrefixes, pkg/auth/surface.go): everything on it accepts an
end-user-less principal; everything off it is fail-closed 400 for lack of an
end user, whatever role the credential might otherwise carry. The 403s are a
second, independent boundary — role, checked inside the handler after the
route has already let the request through. A 400 here always means "wrong
kind of credential for this route"; a 403 always means "right kind, missing
role." Knowing which one you hit before you hit it is exactly what this table
is for.
Know your credential
You do not have to read that table at runtime. Ask the engine:
GET /v1/meIt answers for both lanes — that is the point, and it is the one route in
the /v1/me namespace a machine token reaches. A machine credential:
{
"credential": { "kind": "machine" },
"organization": { "id": "0b5e…", "ref": "acme" },
"application": { "id": "7c31…" },
"subject": null,
"roles": [],
"surfaces": {
"org_level": true,
"user_owned": false,
"org_admin": false,
"platform_admin": false
}
}An end user's token differs in exactly the places that matter — kind is
user, subject.id carries their principal, surfaces.user_owned is true,
and roles lists what the engine records for them in this organization.
| Field | What it tells you |
|---|---|
credential.kind | machine, user, application, operator, or service — the lane, not the scheme |
organization | The engine id every API takes, and the ref your configuration names. null for a credential that names no organization; ref is null for an organization bound to no provider reference |
application | The product (realm) this credential belongs to |
subject | The person, or an explicit null. null is the machine lane's signature. On the tenant lanes subject.id is the engine principal — the id grants, teams and limits key on. On operator and service credentials it is that scheme's own actor label and is not a principal, so read credential.kind before reading meaning into it |
roles | From the engine's own record, never from your token's claims |
surfaces | Present only for a machine or end-user credential. Which classes of route it reaches, as booleans |
surfaces reports the router's admission decision, and it is read from the
router's own predicate rather than restated, so it cannot drift from the 400
you would otherwise get. It is not a promise the call will succeed: several
routes on the org-level side are additionally gated on a role inside the
handler — /v1/limits and /v1/guardrails on org-admin, /v1/connectors on
platform-admin — so org_level: true with org_admin: false still means
403 on those three. That second boundary is what the table above is for; it
is deliberately not folded into a route list here, because a list drawn from
the router alone would name those routes reachable and be confidently wrong.
The key is absent — not false — for application, operator, service,
or any unrecognised credential. Those lanes are gated by individual handler
role checks this endpoint has no registry to summarise, so the router's raw
admission predicates would misreport them: an application credential, for
instance, reaches neither tenant nor platform surfaces, yet both predicates
evaluate true for it. Reporting that as a class summary would be a
confidently wrong capability check, which is worse than reporting nothing.
Until a route-capability registry exists to make the summary honest for every
lane, check "surfaces" in body before reading it.
Use it as a startup check. An app that expects a machine credential and
finds "kind": "user" — or expects to write conversations and finds
"user_owned": false — should refuse to start there and then, rather than
discover it on the first customer request.
GET /v1/me describes the credential you present. It is not RFC 7662 token
introspection, which is a resource server asking about someone else's token;
there is no such endpoint here.
The end-user lane — your users
A person's sign-in must name the organization they are signing in for.
Without it the token carries no organization claim and the engine refuses it
rather than guessing which tenant they meant:
scope=openid organization:acmeA person signs in against the identity provider, not against the engine. The engine is a resource server: it hosts no login page, no authorize endpoint and no callback. Your application runs the authorization-code flow (with PKCE) against the provider itself and presents the resulting token here.
You do not have to hardcode where. The engine publishes it — the authorization
server, the public client id, and the scopes a token must carry — at
GET /.well-known/oauth-protected-resource (RFC 9728), so a client learns the
provider's dialect instead of shipping it:
GET <api>/.well-known/oauth-protected-resource → authorization_servers, client id, scopes_supported
GET <issuer>/.well-known/openid-configuration → authorization_endpoint, token_endpoint, device_authorization_endpointFrom a browser application, run authorization code + PKCE against the
provider's authorization_endpoint, with your redirect URI.
From a terminal, use the device grant (RFC 8628) — again against the provider:
POST <issuer's device_authorization_endpoint> → a code you approve in a browser, on any device
POST <issuer's token_endpoint> (polled until approved) → access token + refresh tokenThe resulting token reaches user-owned endpoints:
Authorization: Bearer <end-user access token>Subjects and principals
The identity provider's sub names someone in its namespace. The engine
resolves it to a principal — a UUID it assigns and owns — and everything
that belongs to a person keys on that: owned rows, dataset and project grants,
team membership, spend limits, ledger rows, audit rows.
When you need to name one of your users — to grant them a dataset, add them to a
team, or cap their spend — use the principal, not the sub. A caller reads
their own with:
GET /v1/me # → { "subject": { "id": "<principal uuid>" }, ... }
GET /v1/me/usage # → { "user_id": "<principal uuid>", ... }Both report the same id. GET /v1/me is the one to reach for: it needs no
end user to answer, so it also tells a machine credential that it has none.
The split is what lets a user deleted and re-created at your identity provider
keep their history: an operator re-binds the new sub to the existing principal
in one write, instead of losing the rows or rewriting every one of them.
What every token is checked against
- The signature, against the issuer's JWKS.
iss, matched exactly.aud, which must carry this application's realm — so a token from another application, or another environment, is refused.- The organization the token names, which must be bound to a Deeplinq
organization. An unbound one is a
401; there is no auto-provisioning. A token naming no organization, or more than one, is refused rather than resolved to a guess.
Roles in a token are not checked, because they are not read. What a person may do comes from the engine's own record, per organization. Reserved platform roles are stripped as well, so a tenant token can never carry platform authority however it was minted. See Who decides what a person may do.
Platform operators
These are the two tenant lanes above. A platform operator managing the
deployment itself — creating applications and organizations, provisioning
identity, granting models — is neither: they sign in the same way a tenant's
human user does (the device grant or the admin console), but against the
platform realm, which holds operators and nothing else.
Authority follows the realm that granted the role, never the role's name: a
tenant naming a role platform-admin inside its own realm confers nothing. Only
a grant in this deployment's console realm counts, so the two lanes cannot be
confused for each other even though both authenticate the same way.
An operator token names no organization — reach one explicitly:
Authorization: Bearer <operator access token>
X-Deeplinq-Admin-Org: <org-uuid>A tenant token presenting that header is refused; only an operator token may
use it. The dl CLI is the
reference implementation: dl auth login runs the identical device grant
described above and needs no configuration beyond which deployment to talk to.
Retired credentials
Engine-issued API keys (sk_dl_...) and tenant-signed JWTs are retired, as
are the X-End-User-Id and X-End-User-Roles headers. Presenting one is a
403 naming the migration — not a 401, which would suggest retrying with the
same kind of credential.
Security rules
- Keep client secrets in a server-side secret manager.
- Use HTTPS for every non-local request.
- Rotate machine credentials using the two rotation slots, with overlap.
- Offboard a person at the identity provider — that is what stops them authenticating. Revoking their roles is a separate act, in the console, and it is what stops them acting.
- Never place a Deeplinq credential in client-side JavaScript, mobile bundles, URLs, or logs.
- Never call this API directly from a browser. It is server-to-server only —
no route emits an
Access-Control-Allow-*header — and a browser's CORS preflight is refused with a403naming that, not the401a bad credential would produce.