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 |
| Agreement page / acceptance | GET / POST {base_path}/oauth2/agreement |
| Consent (API) | POST {base_path}/oauth2/consent |
| End session (RP-initiated logout) | GET and POST {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).
Organization membership claims without instance configuration
organizationClaims is a per-client boolean, false by default. An owner or admin of the client's organization can set it on POST /oauth2/create-client or change it through POST /oauth2/update-client. The instance operator can also use Share each member's role in the Apps row on /admin/orgs/{id}. No signet.toml edit or restart is needed. Platform clients refuse any attempt to set this field with 400; it applies only to organization-owned clients.
{"client_id":"CLIENT_ID","update":{"organizationClaims":true}}When enabled, both the ID token and UserInfo contain exactly this membership pair:
| Claim | Value |
|---|---|
org_id | The client's immutable organization ID, never another membership's organization |
org_role | The person's role string stored in that organization, such as owner or member,admin |
The role string is preserved, including comma-separated roles. Under signInRule: "any_user", a person who is not a member still receives tokens normally, with neither claim. When the switch is off, it adds neither claim. Legacy instance mappers remain available; when this switch is on, this membership pair takes precedence over mappers using the same names.
The claims are recomputed on refresh, so a role change appears in the next refreshed ID token and in UserInfo for its new access token. UserInfo for an existing access token retains that token's issuance snapshot. Legacy org_role and membership_orgs mappers also omit their claim when the person has no membership in the bound organization, instead of failing token issuance. Invalid mapper configuration or malformed membership data still refuses issuance.
Role groups
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.
Skip the consent screen for your organization's own app
An owner or admin of the organization that owns a client can set skip_consent to true or false in POST /oauth2/create-client, or inside update on POST /oauth2/update-client. Select the organization as active when creating the client. Plain members cannot set this switch, even on a client they created. An admin may change the switch and sign-in rule together on a client another member created. Other metadata still requires the existing client-management permission. Platform clients (those with no organization) are refused with SKIP_CONSENT_REQUIRES_ORGANIZATION (400). Send a JSON boolean; other values return SKIP_CONSENT_INVALID (400).
This is for your organization's own members-only app: the resulting signInRule must be organization_members when enabling it. invited, off, and any_user return SKIP_CONSENT_REQUIRES_MEMBERS_ONLY (400). Changing the rule away from organization_members while the switch remains on returns SKIP_CONSENT_RULE_CONFLICT (409). Turn skip_consent off first, or send skip_consent: false with the rule change in the same update. Signet never silently clears it.
curl -X POST "$BASE/api/auth/oauth2/update-client" \
-H 'content-type: application/json' \
-H "origin: $BASE" \
-H "cookie: $SESSION_COOKIE" \
-d '{"client_id":"CLIENT_ID","update":{"skip_consent":true}}'With the switch on, an admitted member goes directly to the app's registered redirect URI with an authorization code. Membership and other sign-in checks still apply, and prompt=consent still requests consent explicitly. Clients without the switch keep the existing consent flow. The instance operator's platform-credential route can set it on any client; that separate authority is unchanged.
Who can sign in through an organization-bound client
Each app your organization registers on Signet has its own sign-in rule. You choose who may sign in to that app: everyone in the organization, only people you name, anyone with an account on the instance, or no one. An organization owner or admin sets it through the client API. Nothing else is needed. An instance operator can set the same rule, and the invited people, on the organization's page in the /admin console (/admin/orgs/{id}, section Apps).
curl -X POST "$BASE/api/auth/oauth2/update-client" \
-H 'content-type: application/json' \
-H "cookie: $SESSION_COOKIE" \
-d '{"client_id":"CLIENT_ID","update":{"signInRule":"invited"}}'
curl -X POST "$BASE/api/auth/oauth2/client/invitees/add" \
-H 'content-type: application/json' \
-H "cookie: $SESSION_COOKIE" \
-d '{"client_id":"CLIENT_ID","email":"ada@example.com"}'Two limits, stated plainly. Access tokens already issued keep working until they expire, even after you change the rule or remove someone. And a sign-in code that was already issued still redeems; the rule is checked when a person signs in and when a token is renewed, not at code redemption. The details follow.
This rule governs workforce OAuth/OIDC sign-in, including MCP, registered-client device approval/redemption, ID-JAG and refresh grants. It does not govern application-user sign-in through /app-users/authenticate.
Each organization-owned client has a signInRule setting:
| Value | Who may complete a sign-in |
|---|---|
organization_members (default) | Members of the organization that owns the client |
invited | People whose current organization membership is on this client's invitee list |
off | Nobody, including organization members |
any_user | Any signed-in user with an account on this instance, including non-members |
The rule is checked before consent. Refused refresh grants return invalid_grant and revoke the refresh family, including after membership removal or switching to off. Changing the rule does not immediately retire access tokens; a refused refresh revokes its family and descendant access tokens. Authorization codes minted after passing the rule at their authorize door remain redeemable for their short lifetime, even after membership removal or a rule change. Code redemption does not re-check the rule. Platform clients report signInRule: null. Even submitting the current rule requires organization administrator authority. Interactive authorization refusals redirect to the registered redirect_uri with error=access_denied, a description, the caller's state, and iss.
Set signInRule in the body of POST /oauth2/create-client or inside update on POST /oauth2/update-client; GET /oauth2/get-client and GET /oauth2/get-clients return it. Changing the rule requires an owner or admin of the client's organization. Rule updates without skip_consent, and updates to other metadata, also require the existing client-management permission. Creation with a non-default rule also requires an owner or admin; an ordinary member can create a client with the default.
Manage the per-client list with the following session routes under base_path:
| Route | Request | Success |
|---|---|---|
POST /oauth2/client/invitees/add | {client_id, user_id} or {client_id, email} (exactly one person selector) | {added: true}; an existing entry returns {added: false} |
POST /oauth2/client/invitees/remove | {client_id, user_id} | {removed: true} or {removed: false} |
GET /oauth2/client/invitees?client_id=... | Client query parameter | Array of {user_id, email, name, added_at} |
Reads and writes require an owner or admin of the client's organization, together with the existing client-management permission. Writes also require a valid Origin. A person must already be an organization member before being added; otherwise add returns 400 naming that requirement. Platform clients return 400, and unknown clients use get-client's 404 envelope. Actual additions and removals write audit rows with the old and new invitation; idempotent no-ops do not write a success audit.
With invited, each list entry belongs to the person's current membership in the client's organization. Deleting that membership cascades to its invitations in the same transaction, including SCIM deprovisioning, dashboard removal, remove-member, leave, platform admin DELETE, and invitation-accept rollback. Rejoining does not restore those entries. Removing someone from the list refuses subsequent refresh grants. Previously issued authorization codes still redeem; access tokens remain valid until expiry. The refusal description is: "This app is only open to people its organization has invited. Ask an admin of the organization to add you."
The organization's binding is fixed at creation, with an organization active. Platform-scoped clients (created with no organization active) continue to admit any signed-in user. Sending signInRule for a platform client is refused with 400: the field applies only to organization-owned clients.
With any_user, a non-member may authenticate but still receives no organization roles in the groups claim. Treat an empty groups list according to the app's own access policy; authentication alone does not grant organization membership.
⚠ 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.
Require an agreement before people sign in
Agreement examples checked 2026-10-13.
You can require an NDA or licence before Signet issues a user-bound token for an organization's app, or platform terms on a registered platform app. Clients resolved from metadata documents are unaffected. The client's sign-in rule still runs first. An agreement never admits someone that rule refuses.
Share a seller's agreement with a buyer
The seller owns the agreement and publishes its versions. A platform credential can replace its share list with PUT /admin/v1/organizations/{seller}/agreements/{agreementId}/shares, using {"organization_ids":["buyer-organization-id"]}. The buyer's registered apps can then require that agreement through their own organization client route. An agreement cannot be shared with its owner or attached without permission. To remove a share, first remove the agreement from every buyer app that requires it. Otherwise Signet answers 409 AGREEMENT_SHARE_IN_USE and names those apps.
A buyer reads shared agreements at /admin/v1/organizations/{buyer}/shared-agreements, or adds /{agreementId} to read one. Once unshared, the buyer can still read the versions its acceptance rows reference, but no later text or current renewal rule. The owner's acceptance list keeps the signer's email and version. Buyer user ids, app ids, IP addresses, browser details and recording credentials are empty there, including in CSV and when the reader uses a platform credential. The buyer's own list shows its full rows. Acceptance audit events belong to the buyer.
Let an owner or admin accept once for the organization
agreement_ids is always the complete required list. To set modes, send both lists to the client's PUT route, for example:
{"agreement_ids":["seller-licence","personal-notice"],"organization_agreement_ids":["seller-licence"]}An owner or admin accepts the licence for the organization. Everyone the app's sign-in rule admits is then covered. The notice still asks each person. A person who cannot accept for the organization sees the title and a line asking an owner or admin to act, with no text or accept button. A new version or expired acceptance asks the organization again. The acceptance remains valid if the signer later leaves or loses their role.
Omitting organization_agreement_ids preserves the modes of retained ids and makes newly added ids per person. Removing an id removes its requirement. For an acceptance made elsewhere, add accepted_for: "organization" and for_organization_id to the owner's acceptance POST. The named signer must be an owner or admin now. The organization must own the agreement or hold a share. Omitting these fields records a person's acceptance for the owner, as before. An explicit null or empty organization id is refused. With source_ref, retries match the person, version, source reference, mode and organization together.
Require platform terms on platform apps
A platform credential creates platform agreements at /admin/v1/agreements. Read, change renewal, delete, publish and record through the corresponding /admin/v1/agreements/{agreementId} routes. Read platform acceptance evidence at /admin/v1/agreement-acceptances. Organization credentials cannot read these. Attach the complete list with PUT /admin/v1/clients/{clientId}/agreements. Platform apps require only platform agreements, accepted per person. Organization apps require only their own or shared organization agreements. Platform terms cannot be shared or accepted for an organization. Accepting their page records a person's evidence without an owner organization or an organization accepted for.
Create an agreement and attach it to your app
Use your instance origin for BASE. The admin API lives at the root, outside base_path. Enable [admin] on the instance. Supply a live platform credential through SIGNET_ADMIN_KEY. Writes need its user:create permission. Reads need token:get; an eligible organization-scoped credential can read its own organization. Organization owner/admin sessions cannot manage agreements through these routes.
Choose an existing organization and one of its registered clients:
BASE=https://auth.example.com
ORG_ID=your-organization-id
curl --fail-with-body "$BASE/admin/v1/organizations/$ORG_ID/clients" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY"The response is {"clients":[...]}. Each row has client_id, client_name, redirect_uris, sign_in_rule, agreement_ids, and organization_agreement_ids. It has no client secret. Set CLIENT_ID to the row whose callback address belongs to your app.
Create your agreement. This publishes version 1 and returns HTTP 201:
CLIENT_ID=your-client-id
CREATED=$(curl --fail-with-body -X POST \
"$BASE/admin/v1/organizations/$ORG_ID/agreements" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"title":"NDA","text":"Keep the shared project information private.","renew_after_days":365,"external_id":"legal-nda-1"}')
AGREEMENT_ID=$(printf '%s' "$CREATED" | jq -r '.agreement.id')
printf '%s\n' "$CREATED" | jq .
curl --fail-with-body -X PUT \
"$BASE/admin/v1/organizations/$ORG_ID/clients/$CLIENT_ID/agreements" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg id "$AGREEMENT_ID" '{agreement_ids:[$id]}')"Creation returns agreement, version, and changed: true. The version contains title, body, bodySha256, version, id, agreementId, publishedAt, and publishedBy. bodySha256 is the lowercase SHA-256 of the exact UTF-8 body. The agreement contains its id, organizationId, externalId, renewAfterDays, currentVersion, createdAt, updatedAt, and createdBy.
The PUT returns {"agreement_ids":["your-agreement-id"]} with the real id you sent. It replaces the whole list. Include every agreement you want the app to require. Send {"agreement_ids":[]} to remove all requirements. An agreement from another organization is refused. GET on the same address reads the current list. GET /admin/v1/organizations/{id}/agreements returns agreements, with an agreement and current version in each entry. GET /admin/v1/organizations/{id}/agreements/{agreementId} returns agreement and versions, including every version's full body.
What your person sees
On browser sign-in through /oauth2/authorize or /mcp/authorize, Signet sends the person to {base_path}/oauth2/agreement before the consent screen. It shows the app and organization names, each unmet agreement's title, version and full text, and Accept and continue and Decline buttons. A signed-out person first gets a link to sign in. An impersonation session cannot accept.
Accepting records one acceptance per agreement shown. The person must have an unexpired acceptance of every required agreement's current version. Acceptance is shared across apps that require that same agreement. Signet then resumes sign-in and asks for OAuth consent when needed. If the text changes while the person reads it, Signet shows the new version and asks again.
For device or grant failures, you can send the person to {base_url}{base_path}/oauth2/agreement?client_id={client_id}. After accepting there, they see a message to return to the app. That page issues no token itself.
Handle a missing acceptance at each token door
For browser requests with prompt=none, /oauth2/authorize and /mcp/authorize return this pair to the client's validated redirect URI:
{"error":"interaction_required","error_description":"the person must accept an agreement this app requires; send them to sign in without prompt=none"}Send the person through interactive sign-in again. For the other doors, the fixed error_description is the following line. Signet substitutes your instance URL, base path, and URL-encoded client id:
the person has not accepted the current version of an agreement this app requires; they can read and accept it at {base_url}{base_path}/oauth2/agreement?client_id={client_id}| Door | error | What you do next |
|---|---|---|
Authorization-code redemption at /oauth2/token or /mcp/token | invalid_grant | The code is consumed. Start sign-in again. |
Device approval at /device/approve | access_denied (HTTP 403) | The device code stays pending. Accept, then approve again. |
Device redemption at /device/token | access_denied | The code is denied. Start a new device flow. |
JWT bearer / ID-JAG grant at /oauth2/token | invalid_grant | Send the person to the agreement page, then retry with a valid grant. |
Refresh at /oauth2/token or /mcp/token | invalid_grant | The refresh family is revoked. Start sign-in again. |
Token exchange accepts service-principal subjects and clients only. It issues no user-bound token, so this agreement gate does not apply to it.
Declining records nothing. The person sees why they cannot sign in. In a parked browser flow, the page also offers a link back to the app with this error pair:
{"error":"access_denied","error_description":"the person declined an agreement this app requires"}Publish a new version and set renewal
Send new plain text to publish. Omit title to keep the current title:
curl --fail-with-body -X POST \
"$BASE/admin/v1/organizations/$ORG_ID/agreements/$AGREEMENT_ID/versions" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"text":"Keep the shared project information private. Delete copies when the project ends."}'
curl --fail-with-body -X PATCH \
"$BASE/admin/v1/organizations/$ORG_ID/agreements/$AGREEMENT_ID" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"renew_after_days":90}'Publication returns agreement, version, and changed. Changing the title or body publishes the next version and requires everyone to accept again. Sending the current title and body returns changed: false. Tokens already issued keep running until expiry. The next issuance or renewal checks the new requirement.
PATCH returns {"agreement":...}. Renewal is 1..3650 days. Send {"renew_after_days":null} to clear it. A new acceptance expires that many days after its acceptedAt. Changing renewal affects future acceptances only. It never rewrites an existing expiresAt. To ask everyone again, publish changed text or a changed title.
Record an acceptance made elsewhere
You can record a real acceptance from your own signing system with a platform credential. The person must already exist in Signet. Supply user_id or email; email is matched exactly after lowercasing. An unknown person returns HTTP 404 AGREEMENT_USER_NOT_FOUND.
Hash the exact UTF-8 text the person accepted. Line breaks count. Do not substitute the hash of a PDF or a differently formatted copy. For example, if your system kept the accepted plain text in signed-nda.txt:
TEXT_SHA256=$(shasum -a 256 signed-nda.txt | cut -d ' ' -f 1)
curl --fail-with-body -X POST \
"$BASE/admin/v1/organizations/$ORG_ID/agreements/$AGREEMENT_ID/acceptances" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg hash "$TEXT_SHA256" '{email:"person@example.com",text_sha256:$hash,accepted_at:"2026-10-10T12:00:00Z",source_ref:"signed-record-42",ip_address:"192.0.2.10",user_agent:"Signing system"}')"Without version, Signet selects the latest published version with that exact hash. You can send a published version number explicitly. A hash mismatch returns HTTP 409 AGREEMENT_TEXT_MISMATCH and records nothing. Omit accepted_at to use the database clock. A supplied timestamp cannot be in the future. source_ref, ip_address, and user_agent are optional. Signet stores at most 512 characters of the supplied user agent. Recorded acceptances have source: "recorded", recordedBy set to your credential id, and clientId: null.
The response is {"acceptance":...,"changed":true}. Repeating the same source_ref for the same agreement, person and version returns the existing row with changed: false. Without a source reference, each call records a new row. The hash verifies matching text. Your platform credential attests that the person accepted it. A recorded old version does not satisfy the current-version gate.
List and export your evidence
curl --fail-with-body -G \
"$BASE/admin/v1/organizations/$ORG_ID/agreement-acceptances" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY" \
--data-urlencode "agreement_id=$AGREEMENT_ID" \
--data-urlencode 'since=2026-10-01T00:00:00Z' \
--data-urlencode 'limit=100'
curl --fail-with-body -G \
"$BASE/admin/v1/organizations/$ORG_ID/agreement-acceptances" \
-H "Authorization: Bearer $SIGNET_ADMIN_KEY" \
--data-urlencode "agreement_id=$AGREEMENT_ID" \
--data-urlencode 'format=csv' \
-o acceptances.csvThe JSON response has acceptances and next_cursor. Pass a non-null cursor as cursor to read the next page. Rows are ordered by ascending acceptance id. The default limit is 100; Signet clamps it to 1..1000. You can also filter by user_id and client_id. since includes acceptances at or after that timestamp. CSV exports every matching row and ignores cursor and limit.
CSV columns, in order:
id,organizationId,agreementId,agreementVersionId,version,title,bodySha256,userId,email,clientId,source,sourceRef,recordedBy,ipAddress,userAgent,acceptedAt,expiresAtThe export uses CRLF lines and quoted cells. Null cells are empty. Text starting with a spreadsheet formula prefix gets an apostrophe. Read the agreement's full versions alongside this export to keep the exact accepted text with your evidence.
DELETE /admin/v1/organizations/{id}/agreements/{agreementId} returns {"deleted":true} only when the agreement has no acceptances. Otherwise it returns HTTP 409 AGREEMENT_HAS_ACCEPTANCES. Remove it from the app's required list to stop asking while keeping evidence. Deleting a person keeps the evidence with userId set to null. Deleting the client also keeps it. Deleting the organization removes its agreements and acceptances.
Acceptance is kept apart from OAuth consent. They have separate tables, pages, POST routes and audit events. prompt=consent does not ask for an agreement again. Revoking consent removes no acceptance. The agreement page records an attestation; the consent screen asks what the app may read.
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 |
/oauth2/end-session (RP-initiated logout) | ✅ since SIG-237 (2026-10-11, branch): ends the browser's Signet sessions and returns to an exactly registered post_logout_redirect_uri; front- and back-channel logout and the sid claim are not built |
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.
When a session rule ends a login's access, Signet does not end an app's own login made from an ID token: the app must end its own session, and that login's refresh grant will answer invalid_grant with signet_reason identifying the ending. The existing instance event sink emits session.revoked with reason session_rule for deleted Signet sessions. The separate session.access_ended event is pending; per-app OIDC back-channel logout is not implemented.
{base_path}/oauth2/end-session implements OpenID Connect RP-Initiated Logout 1.0. Send the browser there from your app's Sign out:
- Parameters (query for GET, form body for POST):
id_token_hint(the ID token this server gave your app; an expired one is accepted, a token signed with a key this server no longer publishes is not),client_id,post_logout_redirect_uri,state.ui_localesis ignored. - What it ends: every Signet session the browser presents, with the same audit as ordinary sign-out (
session.revoke, detailsign_out), and it clears the session cookies. It does not end your app's own session; end that first. - Where it sends the browser: to
post_logout_redirect_uriwithstateappended as one encoded query value, only when it equals one of your client'spost_logout_redirect_urisexactly, byte for byte (no prefix, case, port, encoding or trailing-slash leniency), and that registered string is canonical http(s): lower-case host, no default port, no user name, no#fragment, a path of at least/(write it as a browser would). If the request names neitherclient_idnorid_token_hint, the address is ignored and the person gets the signed-out page. With no redirect it shows a plain "You're signed out." page. - Refusals are pages, never JSON, and sign nobody out: an unregistered, near-miss or non-canonical redirect,
client_idnot matching the hint (itsazpwhen present, else itsaud), an unknown or disabled client (400); a hint this server did not sign (401). - Confirmation: a request without
id_token_hint, or whose hint names someone other than the person signed in, shows a "Sign out?" page and signs nobody out until the person presses its button. That button POSTs back from this server's own origin; a cross-site POST without a hint is refused (403). Sendid_token_hintto sign out in one step. - Registering the address:
post_logout_redirect_urisonPOST {base_path}/oauth2/create-clientorupdate-client, or the operator callPOST /admin/v1/oauth-clients/{client_id}with{"post_logout_redirect_uris": [...]}and the platform admin key. - Not built: front-channel and back-channel logout (telling other apps the person signed out), and the
sidclaim in ID tokens.
An ending’s reason is returned to a client only for the organization the door judges (or for a login-wide ending), and a device user-code reason requires the signed-in login owner; Signet’s own sign-in page keeps the person’s reason.