Let another application sign users in with your Signet instance — the same way apps "Sign in with Google". Your instance issues the ID token; you own the users.
Shipped 2026-07-29. Read §7 What is not built yet before you plan around this — the authorization-code flow is complete, several sibling endpoints are not.
1. What you get
A relying party (any OIDC client library) can complete the authorization-code flow with PKCE against a fresh install, with no application code from you:
- discovers your instance via
/.well-known/openid-configuration - redirects the user to
/oauth2/authorize - the user signs in on a page your instance serves
- the user grants consent on a screen your instance serves
- the client receives a
codeat its own registeredredirect_uri - exchanges it at
/oauth2/tokenfor an access token and anid_token - verifies the
id_tokenagainst your published JWKS — signature,iss,aud,nonce,exp
ID-token signing supports EdDSA (Ed25519) and RS256. Your instance mints and publishes its own keys; nothing is shared with Kapable. The client's stored id_token_signed_response_alg selects a usable active lane, and discovery derives its default-first list from those lanes. New installs default an omitted value to RS256; migrated installs with an existing signing key are explicitly pinned to EdDSA for compatibility. This establishes signing-algorithm conformance only, not complete OIDC Core conformance or hosted Tailscale compatibility.
2. Configure
Everything is optional — an instance with no [oauth] section serves the flow with built-in pages.
[oauth]
# Where /oauth2/authorize sends an unauthenticated browser.
# Default "/login" — served BY THIS INSTANCE.
# Point it at your own sign-in page and Signet serves nothing at /login.
login_page = "/login"
# Where /oauth2/authorize sends a browser that must grant consent.
# Default "/consent" — served BY THIS INSTANCE.
# Point it at your own page and Signet serves nothing at /consent; your page must
# POST {accept, scope, oauth_query} to {base_path}/oauth2/consent.
consent_page = "/consent"The generated configuration reference documents the complete [oauth] section.
⚠ The built-in pages exist only while these hold their defaults. Override either one and that page is yours to serve — Signet will not fall back.
3. Endpoints
Everything below is under your base_path (default /api/auth) except the two pages, which are mounted at the root because that is where login_page / consent_page point.
| path | |
|---|---|
| Discovery | {base_path}/.well-known/openid-configuration |
| JWKS | {base_path}/jwks |
| Authorize | GET {base_path}/oauth2/authorize |
| Token | POST {base_path}/oauth2/token |
| UserInfo | GET and POST {base_path}/oauth2/userinfo |
| Introspection | POST {base_path}/oauth2/introspect |
| Revocation | POST {base_path}/oauth2/revoke |
| Consent (API) | POST {base_path}/oauth2/consent |
| End session | GET {base_path}/oauth2/end-session |
| Dynamic registration | POST {base_path}/oauth2/register is deliberately disabled (403); an operator-enabled POST {base_path}/mcp/register is session/Origin-gated and owner-bound in production, while only the isolated conformance profile is anonymous |
| Manage clients | POST {base_path}/oauth2/{create,update,delete}-client, GET {base_path}/oauth2/{get-client,get-clients,public-client} |
| Sign-in page | GET /login (root, not base_path) |
| Consent page | GET /consent (root, not base_path) |
⚠ Your issuer includes the base path
issuer = base_url + base_path e.g. https://auth.acme.com/api/authNot the bare hostname. Every endpoint URL in the discovery document is built from it, and a conforming client will reject metadata whose issuer does not match the host it fetched from (RFC 8414 §3.3). base_url is your instance's identity — see §6.
4. Register a client
OIDC client creation is a signed-in management operation, not public RFC 7591 registration. The request therefore carries the account holder's Signet session cookie:
curl -X POST "$BASE/api/auth/oauth2/create-client" \
-H 'content-type: application/json' \
-H "cookie: $SESSION_COOKIE" \
-d '{
"client_name": "Ledgerline",
"redirect_uris": ["https://ledgerline.example/callback"],
"grant_types": ["authorization_code"],
"response_types":["code"],
"token_endpoint_auth_method":"client_secret_basic"
}'token_endpoint_auth_method: "none" makes it a public client (PKCE required, no secret).
groups_claim is an optional per-client disclosure switch and defaults to false. When true, ID tokens carry the user's role names in the one organization this client is bound to — groups: ["owner"], or groups: ["admin","member"] for someone holding two roles. The values are drawn from owner, admin and member and nothing else, sorted and de-duplicated. When false or omitted, the groups claim is absent; an enabled client receives groups: [] when the user is not a member of its organization.
The claim never names the organization, so every value is a bare role. That matters for real consumers: Proxmox VE accepts only [A-Za-z0-9.\-_]+ as a group name, validates the claim value before applying its own realm suffix, and on rejection logs a warning and completes the login anyway — so a group name it dislikes produces a session with no permissions that looks like a successful integration.
The switch requires an organization-bound client. A client is bound when it is created in a session that has an organization selected as active; a platform-scoped client has no organization, and the API refuses groups_claim on it with GROUPS_CLAIM_REQUIRES_ORGANIZATION. The binding is set at creation and cannot be moved afterwards, so renaming an organization does not break any group mapping — the organization's slug never appears in a token.
⚠ Group names are per-client. Do not share one group map across clients of different organizations. Because the value is a bare role, admin from organization A and admin from organization B are the same string. A relying party that maps groups to privileges per client or per realm — which is what Proxmox VE and Headscale both do — is unaffected. A relying party with a single global group table serving two Signet clients would grant B's admins whatever it grants A's. Give each client its own mapping.
Who can sign in through an organization-bound client
Only members of that organization. A client bound to an organization refuses authorization for anyone who is not a member of it, before consent, with a 403 naming the organization and telling the person to ask an administrator for access. A platform-scoped client is unaffected and admits anyone with an account on the instance.
This is the distinction between the two client scopes, and it is worth choosing deliberately at creation time, because the binding is fixed then and cannot be moved afterwards:
| Client scope | Who may complete a sign-in |
|---|---|
| Organization-bound (created with an organization active) | members of that organization only |
| Platform-scoped (created with no organization active) | anyone with an account on the instance |
⚠ This applies to the workforce sign-in path, not to your application's own end users. If your application has customers who sign in — people who are not staff of your organization — they are application users and reach a different path entirely. Do not add customers to your organization as members to work around this; membership carries organization roles and administrative meaning.
⚠ An empty groups list is not a grant. A user who authenticates successfully but is not a member of the client's organization receives groups: []. Authorization to an organization-bound client is not membership-gated, so do not configure a relying party to treat "authenticated with no groups" as a default level of access.
⚠ If you deployed the 2026-08-23 build, the claim emitted <organization-slug>:<role>. Group mappings built against those values must be re-pointed at bare role names before upgrading.
To enable it at creation, add "groups_claim": true to the request above. The signed-in owner can also change the switch through the same client-management API:
curl -X POST "$BASE/api/auth/oauth2/update-client" \
-H 'content-type: application/json' \
-H "cookie: $SESSION_COOKIE" \
-d '{"client_id":"CLIENT_ID","update":{"groups_claim":true}}'⚠ redirect_uri is matched EXACTLY. No prefix matching, no wildcards, no trailing-slash tolerance. https://good.example.evil.com does not satisfy https://good.example. Register every callback you will actually use — and if the stored list is unreadable for any reason, the instance refuses all of them rather than guessing.
5. Verify it works
Verify the whole flow with a browser test that drives the real sign-in and consent pages, exchanges the authorization code, and validates the id_token against the published JWKS. Use an isolated test instance and throwaway database, and make any failed step fail the test.
6. ⚠ Choose your hostname before you have users
base_url determines your issuer, and the issuer is your instance's identity. Changing it later:
- invalidates every
id_tokenalready issued and every client's cached discovery document; - changes the WebAuthn Relying Party ID, which permanently destroys every registered passkey — those credentials live in hardware you do not control and cannot be migrated;
- breaks email links, and social/SAML callback URLs registered with upstream providers.
Sessions are domain-scoped cookies, so users are logged out; user rows themselves survive.
Settle your final hostname — including a custom domain — before the first real sign-in.
Secondary hostnames redirect to the canonical one. Because the issuer is built from base_url, serving auth under any other hostname would hand clients a discovery document whose issuer does not match the URL it came from — the mix-up condition RFC 8414 §3.3 forbids. So the instance serves auth only under the host of its base_url and answers every other host with 308 Permanent Redirect to the same path and query on the canonical host. This matters whenever an instance keeps a second name it cannot remove — a platform-derived hostname, or a legacy one you have migrated off. There is no setting to turn it off; if a hostname should work, make it the base_url. Liveness paths (/health, /__health) are exempt, because health probes cannot send the canonical host.
7. What is not built yet
Honest gaps as of 2026-08-25. Do not plan around these existing:
| status | |
|---|---|
authorization_code grant + PKCE | ✅ complete |
id_token (EdDSA + RS256) | ✅ per-client selection; discovery derives the default-first list from usable active key lanes |
| Consent, scope narrowing | ✅ complete |
/oauth2/userinfo (GET + POST) | ✅ complete |
/oauth2/introspect | ✅ complete — access and refresh tokens |
/oauth2/revoke | ✅ complete — access and refresh tokens |
refresh_token grant | ✅ complete — issued only for offline_access, rotates on use, and tears down the client/user family on reuse |
client_credentials grant | ✅ complete for confidential clients registered for that grant; issues an access token with no user, ID token, or refresh token |
RFC 8707 resource at authorize | ✅ up to eight canonical absolute URIs per request, on /oauth2/authorize and /mcp/authorize; binds the issued access token and survives refresh rotation |
| The binding is ENFORCED, not merely stored | ✅ /oauth2/introspect answers invalid_target for a token bound elsewhere, and since 2026-08-18 /mcp/get-session and /mcp/userinfo resolve no session for one either — measured against the resource this instance's own RFC 9728 document advertises. A token with no binding stays unrestricted |
urn:ietf:params:oauth:grant-type:jwt-bearer (MCP EMA / ID-JAG) | ⚙️ compile-gated — present only in a binary built --features external-jwt, which also adds authorization_grant_profiles_supported: ["urn:ietf:params:oauth:grant-profile:id-jag"] to both discovery documents. A default build advertises neither and answers unsupported_grant_type, so an EMA client fails safe. Building it does not pull in SAML |
private_key_jwt client authentication (RFC 7523 §2.2) | ⚙️ compile-gated on --features external-jwt. A confidential client with no shared secret authenticates by signing a short-lived assertion with a key it published. A default build refuses to register the method and says so, rather than storing a credential it cannot verify |
Client ID Metadata Documents (an https URL as client_id) | ⚙️ compile-gated on --features external-jwt, advertised as client_id_metadata_document_supported: true. Honoured at the token endpoint's client authentication only — a URL client_id at /oauth2/authorize or /mcp/authorize is still an unknown client |
| RS256 signing | ✅ new installs default omissions to RS256; migrated estates remain explicitly EdDSA; unavailable lanes are refused at every client-write route |
| Credentials hashed at rest | ✅ client secrets, authorization codes, refresh tokens, and OAuth/MCP access tokens; no plaintext fallback |
The discovery document once advertised userinfo_endpoint, introspection_endpoint and revocation_endpoint while all three returned 404 — the founding defect of the provider. All three are now served, and all seven advertised endpoints have been verified live since 2026-07-29.
Introspection and revocation boundaries
Both operate on access and refresh tokens, but their hints have different contracts. For introspection, RFC 7662 §2.1 requires a missed hinted lookup to extend across the server's other supported token types: a recognized hint orders the search, an unknown string is ignored, and neither can fence a live token off. For revocation, a valid hint still narrows the operation to that class and an invalid hint is refused; RFC 7009 policy is separate from introspection. Two other behaviours are worth knowing before you build against them:
- A public client cannot introspect. RFC 7662 requires the endpoint to authenticate its caller, and a public client has no secret to authenticate with. It can revoke its own tokens — revocation requires only
client_id. - Introspection is scoped to the caller's own tokens. Asking about another client's token answers
{"active": false}, exactly as an unknown token does. Revoking another client's token answers200and changes nothing.
Authenticating a client without a shared secret
Available in a binary built --features external-jwt. Register the client with token_endpoint_auth_method: "private_key_jwt" and exactly one key source — jwks (an RFC 7517 key set, by value) or jwks_uri (by reference). Sending both is refused; RFC 7591 §2 says they must not both be present, and a server that had to pick one would be a server an attacker could steer. Registering the method with neither is also refused: that client could never authenticate, and a row whose only possible outcome is an undiagnosable refusal is worse than a rejected registration.
No client_secret is minted for such a client. Its credential is a signature, and a shared secret sitting on the row beside the key would be a second, weaker way in that nothing rotates.
At the token endpoint the client sends client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and a client_assertion JWT with iss and sub both equal to its client_id, an aud naming either this server's issuer identifier or its token endpoint URL (RFC 7523 §3 item 3 admits both), a jti, and an exp inside an hour. Presenting a client_secret in the same request is refused — RFC 6749 §2.3 says a client must not use more than one authentication method per request.
⚠ Every failure answers one invalid_client with one description, deliberately. An assertion audienced at another server and an assertion signed with a key you never published are indistinguishable on the wire, because a caller able to tell them apart learns which of its guesses was closer. The server log names the check that refused it, and every one of those log lines states the limit and the corrective action.
Client ID Metadata Documents
Available in the same builds, advertised as client_id_metadata_document_supported: true (MCP 2026-07-28). A client that has never registered here can present an https URL as its client_id; this server fetches that URL and reads the RFC 7591 metadata document it serves.
What the URL must be: https, with a path component, no fragment, no userinfo, no query string, on a publicly routable host, and in exactly the form you present it — if the URL parser would rewrite your identifier at all (a dot segment, a default port, an escape spelled differently), it is refused, because the document we fetch must come from the URL you named and no other. The document must declare a client_id byte-equal to that URL, carry client_name and redirect_uris, be served as JSON, and stay under 5 KiB. Redirects are never followed. It may declare token_endpoint_auth_method of none or private_key_jwt — never a client_secret_* method, since there is no shared secret to establish.
Two limits worth knowing before you build against this:
- A registered client always wins. If a client is registered here under that exact
client_id, its stored metadata is used and no document is fetched. A published document cannot shadow, widen or impersonate a client an operator registered. - A CIMD identity carries no organization, so it cannot redeem an enterprise ID-JAG at the jwt-bearer grant — that grant reads the organization from the authenticated client row, and a client that registered itself by publishing a document has none. Register the client inside the organization whose identity provider issues the assertion.
⚠ Like the client-assertion refusals above, every CIMD refusal is the same invalid_client an unknown client_id already gets. Telling them apart would map what this server will and will not fetch. The cause is in the server log.
Access-token lifetime
[oauth] access_ttl_seconds sets how long an OAuth2 and MCP access token lives. The default is 3600 — the hour both flows always used, so an instance that names no value is unchanged. Accepted range is 300–3600; anything outside it refuses at boot naming the range.
An agent that holds a token for the length of a task wants the short end — 300 to 1800, five to thirty minutes. A leaked bearer is then useful for minutes rather than an hour. The cost is a refresh round-trip per interval for clients holding offline_access, and nothing else: the ID token and refresh token keep their own lifetimes.
One key moves both doors deliberately. An instance with a five-minute OAuth token and an hour-long MCP token would be a hole with a configuration screen in front of it.
Naming the resource a token is for
An authorization request may carry RFC 8707's resource — the absolute URI of the API the token will be presented to. Signet canonicalizes it with the same parser the token-exchange grant uses (no fragment, no query, no wildcard, at most 2048 bytes), records it on the authorization code, and copies it onto the issued access token. A resource-aware introspection call then answers active only at that target and invalid_target anywhere else (the universal door /tokens/introspect refuses the same case as TOKEN_RESOURCE_MISMATCH).
Repeat resource to name several targets. RFC 8707 §2's own spelling works: up to eight distinct canonical values in one authorization request, and the resulting token is live at every one of them. Duplicates and the ninth value are refused. The stored set is sorted and de-duplicated, so it is the canonical array rather than your wire order.
The whole set survives the detour. A request parked at the login page, or handed to the consent screen and posted back, resumes with every target it named — not a subset, which would be widening by omission.
A refreshed token keeps its targets. refresh_token rotation copies the set from the row being rotated, never from the request, so a refresh can neither widen nor invent a target its authorization did not grant. A token whose authorization named no target stays unbound, and an unbound token is refused — TOKEN_RESOURCE_UNBOUND — at any door that supplies an expected resource. Absence means unrestricted-by-authorization, and a caller that named a target never receives one as though it were confined.
Two things are still refused, and both on purpose:
- Every other repeated parameter.
resourceis the only field that may appear twice. A repeatedstate,scope,client_idorredirect_uriis answered with a400naming the field (RFC 6749 §3.1), because a refusal that can be walked past by appending an acceptable value is order-dependent and therefore not a refusal. - Uncanonical values. Refused at
authorizewith RFC 8707's owninvalid_target, delivered to the client's registeredredirect_uri, with no code minted — never at the token endpoint, after a code has already been handed over.
⚠ There is no resource_indicators_supported in the discovery documents, and that is not an omission. RFC 8707 registers the resource parameter and the invalid_target error and defines no authorization-server metadata parameter; none is in the IANA registry. Advertising one would publish a member no client is specified to read. MCP clients learn the identifier to send from the RFC 9728 protected-resource document at /.well-known/oauth-protected-resource, which names it in its resource field.
⚠ In that same document, resource and authorization_servers differ, and both are right. resource is this protected resource's own identifier — the origin, and the value an MCP client sends as RFC 8707 resource. authorization_servers holds RFC 8414 issuer identifiers (RFC 9728 §2), so its entry carries the base path: https://<host>/api/auth, not https://<host>. That is the value to configure in an enterprise IdP. Resolving it the way RFC 8414 §3.1 prescribes — inserting /.well-known/oauth-authorization-server between the host and the path — reaches this instance's own authorization-server metadata, and a gate fails the build if it ever stops doing so. Before 2026-08-17 this field emitted the origin, which resolved to nothing.