Getting started

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:

SurfaceMachine 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/limits403 org-admin required — reachable route, role-gated
/v1/guardrails, /v1/org/connectors403 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 above400 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/me

It 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.

FieldWhat it tells you
credential.kindmachine, user, application, operator, or service — the lane, not the scheme
organizationThe 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
applicationThe product (realm) this credential belongs to
subjectThe 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
rolesFrom the engine's own record, never from your token's claims
surfacesPresent 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:acme

A 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_endpoint

From 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 token

The 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 a 403 naming that, not the 401 a bad credential would produce.

On this page