Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.
Quickstart
This page has two parts:
- One command runs the whole system on your machine with Docker Compose: an identity provider, the control plane, a tool server and an agent acting for a demo user.
- By hand installs the control plane without Docker, registers an agent and exchanges a token. Do this before you deploy anything.
Run it with one command
Section titled “Run it with one command”cd quickstartdocker compose upThis starts Postgres, Keycloak, the control plane, a sample tool server, a demo agent and a
portal. A one-shot subactid-migrate service applies the schema before the control plane starts.
The demo agent then runs through the flow and prints each step in the logs. The first run builds
the images, which takes most of the time.
[3/8] the agent exchanges the human's token for a scoped task token ok task task_01M2A5WB4S9FCX3QV4DPM088AR started sub 11111111-1111-4111-8111-111111111111 <- the human, not the agent act.sub agent:demo-agent <- the agent, as the actor scope jira:read jira:comment expires_in 300s, while the task runs until 2026-09-12 07:14:58ZThe agent prints eight steps:
| Step | What happens |
|---|---|
| 1 | The demo user signs in at Keycloak and gets an ordinary access token. |
| 2 | The agent is registered with the scopes, audiences and lifetimes it may ever have. |
| 3 | The agent exchanges the user’s token for a task token: sub is the user, the agent is in act. |
| 4 | The tool server accepts the token. It checks search locally and introspects comment with the control plane. |
| 5 | The agent refreshes the token with a narrower scope. The tool server then refuses comment. |
| 6 | The agent asks for a scope nobody granted. The control plane refuses with invalid_scope. |
| 7 | An operator kills the task. The unexpired token stops working on the next introspected call. |
| 8 | The agent prints the audit ledger for the user: every allow and every deny. |
Use the portal
Section titled “Use the portal”Once everything is up, open http://localhost:8090 and sign in as demo (password demo) or
viewer (password viewer). The portal shows two tokens side by side: yours from Keycloak, and
the one the agent got in exchange. Both have the same sub. The agent’s token also has act,
which names the agent.
Things to try:
- Sign in as each user.
demohas the realm rolejira-commenterandviewerdoes not. Keycloak putsjira:commentin the token only for users with that role. The same agent, asking for the same scopes, getsjira:read jira:commentfordemoandjira:readforviewer. The tool server refusescommentforviewer. - Send your own token to the tool server instead of the agent’s. The tool server answers
401: it accepts only task tokens from this control plane. - Start a long task. Its tokens last 30 seconds and the task runs for 10 minutes. The renewal counter climbs while the task keeps working.
- Revoke a running task, then call
comment. The token is unexpired and correctly signed, butcommentis introspected on every call, so it is refused.
The portal holds the admin API key to show the ledger, and by default it collects your password itself instead of redirecting to Keycloak. A real application does neither.
Read the ledger
Section titled “Read the ledger”Everything keeps running after the demo. All ports are bound to 127.0.0.1: the control plane
is on http://localhost:5100, the portal on 8090, the tool server on 8082 and Keycloak on
8080. To read the audit ledger for the demo user:
ADMIN_KEY=$(docker compose exec -T subactid cat /etc/subactid/admin-key)curl -s -H "Authorization: Bearer $ADMIN_KEY" \ 'http://localhost:5100/audit?sponsor=11111111-1111-4111-8111-111111111111&limit=50'docker compose down -v stops everything and deletes the database and the agents’ keys.
This is a demonstration on one machine, not a deployment:
- Traffic inside the compose network is plain HTTP.
- The credentials in
compose.yamlprotect nothing. - The control plane’s signing key, the admin API key and a demo certificate authority are generated when the images are built. The agents’ keys are created on first run.
To run Subact ID for real, see Operate it.
Install it by hand
Section titled “Install it by hand”This part needs no Docker. It follows the steps of a real install.
What you need
Section titled “What you need”- .NET 10 SDK.
dotnet --versionshould print10.x. - An OIDC identity provider. This page uses Keycloak. Subact ID does not authenticate anyone itself; it takes a user’s access token from your provider as the subject of an exchange. For another provider, see connect your identity provider.
- A database. Postgres 16 for anything real. For a trial, the embedded SQLite provider needs only a file path.
SubactId.Server below stands for the server binary. From a checkout, run
dotnet run --project src/SubactId.Server -- <command>. In the container image, pass the same words
as the container’s arguments.
-
Make a signing key. The control plane signs task tokens with it. Outside the
Developmentenvironment, the server refuses to start without a configured key.Terminal window SubactId.Server keys generate --out ./signing.pemOutput Wrote a new P-256 signing key to ./signing.pem, readable by its owner only.Key id: 9Fy-qRMxKnbHaNBF9nwF68GQVxzhgz3miPA15FHHv38 (RFC 7638 thumbprint)Configure the server with:SubactId__Signing__Keys__0__Path=./signing.pemThe key id is the RFC 7638 thumbprint of the public key. It appears in the JWKS and in the header of every token signed with the key, so a verifier can pick the right key. The private key is written only to the file, never printed.
-
Write the configuration. Every setting is an environment variable. The setting
SubactId:Section:Keyis the variableSubactId__Section__Key.env.sh export SubactId__Issuer=http://127.0.0.1:5100# The embedded database. For Postgres, set SubactId__Database__ConnectionString instead.export SubactId__Database__Provider=sqliteexport SubactId__Database__Path=./subactid.dbexport SubactId__Signing__Keys__0__Path=./signing.pemexport SubactId__Admin__ApiKey=$(openssl rand -base64 32)# Your identity provider's realm URL. Subact ID derives discovery from it.export SubactId__UpstreamIdp__Issuer=https://localhost:8443/realms/mainexport SubactId__UpstreamIdp__Audience=subactid# The default sponsor-check mode, poll, asks Keycloak's admin API whether a person is still# active. On a provider without that API, set SubactId__UpstreamIdp__SponsorCheck__Mode=signals# and leave these three out.export SubactId__UpstreamIdp__SponsorCheck__UsersUrl=https://localhost:8443/admin/realms/main/usersexport SubactId__UpstreamIdp__SponsorCheck__TokenUrl=https://localhost:8443/realms/main/protocol/openid-connect/tokenexport SubactId__UpstreamIdp__SponsorCheck__ClientId=subactidexport ASPNETCORE_URLS=http://127.0.0.1:5100export ASPNETCORE_ENVIRONMENT=ProductionThe admin key must be at least 32 characters. Without it, the admin API is off and every
/adminrequest answers503. -
Apply the schema. The server never runs migrations at startup;
migratedoes.Terminal window source env.shSubactId.Server migrateOutput Applied 14 migration(s):- 20260912075307_InitialSchema- 20260912131102_AddAgentJwks- 20260912132216_WidenAuditReason- 20260913223135_AddAuditEventCount- 20260915165935_AddAuditEventChain- 20260917123328_AddSponsorBlocks- 20260917161011_AddSponsorRevocations- 20260917185250_AddSessionsAndSignalReplays- 20260917213047_AddScimUsers- 20260918094520_ClearDeliveredOutboxEntries- 20260918223300_AddDenialIndex- 20260919094512_AddAuditCheckpoints- 20260919160012_AddAuditArchives- 20260920185041_AddTaskGrantRenewalsSchema version: 20260920185041_AddTaskGrantRenewals -
Check the setup.
doctorchecks the configuration, the signing key, the identity provider, the database, the audit ledger’s partitions, the admin API and the sponsor check, and reports which optional receivers are on. It prints one line per check and exits non-zero if any check fails.Terminal window SubactId.Server doctorOutput ok configuration Loaded.ok signing key Active kid '9Fy-qRMxKnbHaNBF9nwF68GQVxzhgz3miPA15FHHv38', 1 key(s) published.FAIL upstream The discovery document could not be fetched.Likely cause: nothing is serving https://localhost:8443/realms/main from here, or the request timed out.Check the host is resolvable and reachable from this pod, and that SubactId__UpstreamIdp__Issuer is the realm URL.ok database Reachable (sqlite), schema up to date.ok audit partitions Not partitioned; the embedded provider keeps the ledger in one table.Records are never removed from it: detaching a partition is how a ledger sheds history without weakening the guard, and there is none to detach here.ok admin API Enabled; /admin requires the configured key.ok sponsor check Mode 'poll'; the identity provider is asked at https://localhost:8443/admin/realms/main/users, reusing an answer for at most 00:00:30.This control plane authenticates there as 'subactid' with a signed assertion, whose service account needs to be able to read users.ok SCIM receiver Not configured; /scim answers 404.ok signals receiver Not configured; /events answers 404.9 check(s), 1 failed.This is the most common failure: the identity provider is not running, or this machine cannot reach it at the configured realm URL.
-
Start it.
Terminal window SubactId.Server -
Fetch the discovery document. The control plane is its own OIDC issuer.
Terminal window curl -s http://127.0.0.1:5100/.well-known/openid-configurationResponse {"issuer": "http://127.0.0.1:5100","token_endpoint": "http://127.0.0.1:5100/oauth2/token","introspection_endpoint": "http://127.0.0.1:5100/oauth2/introspect","revocation_endpoint": "http://127.0.0.1:5100/oauth2/revoke","jwks_uri": "http://127.0.0.1:5100/.well-known/jwks.json","grant_types_supported": ["urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"],"token_endpoint_auth_methods_supported": ["private_key_jwt"]}The only client authentication method is
private_key_jwt: the agent proves it holds a key without sending it.client_secret_postis not supported.
Register an agent
Section titled “Register an agent”A registration sets the most an agent may ever do. Keep it in a repository and change it through review.
-
Write the registration and the agent’s key.
Terminal window SubactId.Server agent init jira-triage --out agents/Output Wrote a registration for 'jira-triage':agents/jira-triage.yaml the registration; fill in allowed_scopes and allowed_audiencesagents/jira-triage.jwks.json its public keys, the same set the registration embedsagents/jira-triage.key.pem its private key, readable by its owner onlyKey id: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw (RFC 7638 thumbprint)The private key belongs in a secret store, not in the repository. Everything else hereis public and is meant to be committed.The defaults are tight:
sponsor_required: true, a 30-minute task, a 5-minute token and delegation depth 1.allowed_scopesandallowed_audiencesare empty, andapplyrefuses the file, even with--dry-run, until both are filled in. -
Fill in what it may do, then apply it.
agents/jira-triage.yaml agent_id: jira-triagedisplay_name: Jira triage agentsponsor_required: trueallowed_scopes: [jira:read, jira:comment]allowed_audiences: [https://jira.internal]max_task_ttl: PT30Mmax_token_ttl: PT5Mmax_delegation_depth: 1high_risk_audiences: [https://jira.internal]jwks:keys:- kid: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavwkty: ECcrv: P-256x: Jc2LJFZnn6MzLIVes3fkOzE9AuUISXBnXi-kKPq1mFIy: xxcT-A9tys4KKQPFsCZulIHCSe-GWFo68V8iit5sKiIalg: ES256use: sigTerminal window export SUBACTID_ADMIN_KEY=$SubactId__Admin__ApiKeySubactId.Server agent apply agents/jira-triage.yaml --server http://127.0.0.1:5100Output created jira-triage1 file(s), 1 changed.Run it again and it prints
unchanged, after one read and no write.applycreates a missing agent and patches only the fields that differ. It goes through the admin API, so every create and update is in the audit ledger. -
Check the agent’s keys. The registration carries the agent’s public keys, so the control plane serves them and the agent needs no public endpoint:
Terminal window curl -s http://127.0.0.1:5100/agents/jira-triage/jwks.jsonResponse {"keys": [{"kid": "gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw","kty": "EC","alg": "ES256","use": "sig","crv": "P-256","x": "Jc2LJFZnn6MzLIVes3fkOzE9AuUISXBnXi-kKPq1mFI","y": "xxcT-A9tys4KKQPFsCZulIHCSe-GWFo68V8iit5sKiI"}]}An agent may instead publish its own key set and register a
jwks_uri, which must be an absolute HTTPS URL. A registration sets exactly one ofjwksandjwks_uri.
Register an agent explains every field.
Exchange a token
Section titled “Exchange a token”You need a user’s access token from your identity provider and an assertion signed with the agent’s key.
-
Get a user’s access token from your provider. On a development realm, a password grant is the quickest way. The token’s
audmust includeSubactId__UpstreamIdp__Audience. -
Build the agent’s assertion. This is a compact JWS signed with the key
agent initwrote (ES256,RS256orPS256), with that key’skidin the header and these claims:Claim Value issjira-triagesubjira-triageaudhttp://127.0.0.1:5100orhttp://127.0.0.1:5100/oauth2/tokenjtiA value never used before, at most 256 characters expAt most five minutes ahead iatOptional; not in the future @subactid/clientbuilds the assertion for you. See Build an agent. -
Ask for the token.
Terminal window curl -s -X POST http://127.0.0.1:5100/oauth2/token \-H 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \--data-urlencode "subject_token=$USER_TOKEN" \--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \--data-urlencode "actor_token=$AGENT_ASSERTION" \--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:jwt' \--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \--data-urlencode 'resource=https://jira.internal' \--data-urlencode 'scope=jira:read jira:comment'200 OK {"access_token": "eyJhbGciOi…","issued_token_type": "urn:ietf:params:oauth:token-type:access_token","token_type": "Bearer","expires_in": 300,"scope": "jira:read jira:comment","refresh_token": "task_grant_8f2c…","task_id": "task_01HQZX9K4M","task_expires_at": "2026-09-09T14:32:00Z"}
access_token is a task token. Its sub is the user who signed in at your identity provider,
and the agent is in act.
When it does not work
Section titled “When it does not work”| What you see | What it means |
|---|---|
| The server refuses to start | It lists every missing or invalid setting at once. It never repeats a configured value. |
503 from /admin/agents |
SubactId__Admin__ApiKey is not set, so the admin API is off. |
401 from /admin/agents |
The key is wrong. The refusal is in the audit ledger as admin.denied. |
invalid_request, actor_token is required |
The exchange was sent without the agent’s assertion. |
invalid_client |
The assertion was refused: wrong key, wrong aud, expired, a reused jti, or an unknown agent. |
invalid_grant |
The subject token is expired, invalid, not from the configured provider, or has no usable value for the claim Subact ID identifies users by. |
access_denied |
The agent is disabled, or the user is blocked, disabled or deleted. |
invalid_scope |
The intersection of user, agent and request is empty. |
invalid_target |
The audience is not in the agent’s allowed_audiences. |
temporarily_unavailable |
The identity provider’s keys, the agent’s keys or the user’s status could not be fetched, or the control plane is at capacity or cannot reach its database. Retry, after Retry-After when it is sent. |
slow_down, 429 |
Too many requests from one source. Wait for Retry-After. Behind a proxy, set SubactId__RateLimit__TrustedProxies, or every caller counts as one source. |
For problems with the identity provider, such as an issuer that does not match the URL Subact ID
reaches it on, run SubactId.Server doctor. To check a real user token against the configuration,
pipe it to SubactId.Server doctor --subject-token -.
Every refusal is also written to the audit ledger with its reason:
curl -s "http://127.0.0.1:5100/audit?decision=deny&limit=5" \ -H "Authorization: Bearer $SubactId__Admin__ApiKey"Your first exchange explains each field of the response and each claim of the token.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.