Skip to content

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.
Terminal window
cd quickstart
docker compose up

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

What it looks like
[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:58Z

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

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. demo has the realm role jira-commenter and viewer does not. Keycloak puts jira:comment in the token only for users with that role. The same agent, asking for the same scopes, gets jira:read jira:comment for demo and jira:read for viewer. The tool server refuses comment for viewer.
  • 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, but comment is 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.

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:

Terminal window
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.yaml protect 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.

This part needs no Docker. It follows the steps of a real install.

  • .NET 10 SDK. dotnet --version should print 10.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.

  1. Make a signing key. The control plane signs task tokens with it. Outside the Development environment, the server refuses to start without a configured key.

    Terminal window
    SubactId.Server keys generate --out ./signing.pem
    Output
    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.pem

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

  2. Write the configuration. Every setting is an environment variable. The setting SubactId:Section:Key is the variable SubactId__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=sqlite
    export SubactId__Database__Path=./subactid.db
    export SubactId__Signing__Keys__0__Path=./signing.pem
    export 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/main
    export 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/users
    export SubactId__UpstreamIdp__SponsorCheck__TokenUrl=https://localhost:8443/realms/main/protocol/openid-connect/token
    export SubactId__UpstreamIdp__SponsorCheck__ClientId=subactid
    export ASPNETCORE_URLS=http://127.0.0.1:5100
    export ASPNETCORE_ENVIRONMENT=Production

    The admin key must be at least 32 characters. Without it, the admin API is off and every /admin request answers 503.

  3. Apply the schema. The server never runs migrations at startup; migrate does.

    Terminal window
    source env.sh
    SubactId.Server migrate
    Output
    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_AddTaskGrantRenewals
    Schema version: 20260920185041_AddTaskGrantRenewals
  4. Check the setup. doctor checks 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 doctor
    Output
    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.

  5. Start it.

    Terminal window
    SubactId.Server
  6. 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-configuration
    Response
    {
    "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_post is not supported.

A registration sets the most an agent may ever do. Keep it in a repository and change it through review.

  1. 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_audiences
    agents/jira-triage.jwks.json its public keys, the same set the registration embeds
    agents/jira-triage.key.pem its private key, readable by its owner only
    Key id: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw (RFC 7638 thumbprint)
    The private key belongs in a secret store, not in the repository. Everything else here
    is 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_scopes and allowed_audiences are empty, and apply refuses the file, even with --dry-run, until both are filled in.

  2. Fill in what it may do, then apply it.

    agents/jira-triage.yaml
    agent_id: jira-triage
    display_name: Jira triage agent
    sponsor_required: true
    allowed_scopes: [jira:read, jira:comment]
    allowed_audiences: [https://jira.internal]
    max_task_ttl: PT30M
    max_token_ttl: PT5M
    max_delegation_depth: 1
    high_risk_audiences: [https://jira.internal]
    jwks:
    keys:
    - kid: gfjXIu9dHb5E9KJfKdR4BhczXLzwa_cokBPzsdlxavw
    kty: EC
    crv: P-256
    x: Jc2LJFZnn6MzLIVes3fkOzE9AuUISXBnXi-kKPq1mFI
    y: xxcT-A9tys4KKQPFsCZulIHCSe-GWFo68V8iit5sKiI
    alg: ES256
    use: sig
    Terminal window
    export SUBACTID_ADMIN_KEY=$SubactId__Admin__ApiKey
    SubactId.Server agent apply agents/jira-triage.yaml --server http://127.0.0.1:5100
    Output
    created jira-triage
    1 file(s), 1 changed.

    Run it again and it prints unchanged, after one read and no write. apply creates 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.

  3. 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.json
    Response
    {
    "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 of jwks and jwks_uri.

Register an agent explains every field.

You need a user’s access token from your identity provider and an assertion signed with the agent’s key.

  1. Get a user’s access token from your provider. On a development realm, a password grant is the quickest way. The token’s aud must include SubactId__UpstreamIdp__Audience.

  2. Build the agent’s assertion. This is a compact JWS signed with the key agent init wrote (ES256, RS256 or PS256), with that key’s kid in the header and these claims:

    Claim Value
    iss jira-triage
    sub jira-triage
    aud http://127.0.0.1:5100 or http://127.0.0.1:5100/oauth2/token
    jti A value never used before, at most 256 characters
    exp At most five minutes ahead
    iat Optional; not in the future

    @subactid/client builds the assertion for you. See Build an agent.

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

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:

Terminal window
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.

Subact ID Pre-release. v0.1 is not out yet.

© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.

LegalTermsPrivacy