Service accounts
How a program — a Claude Code session, a CI job, a script — holds an account of its own, and how to set one up.
How it works
A service account is an account like any other, held by a program instead of a person. It has an id, a name and role grants; what it may do is decided by the permissions its roles carry, exactly as for a person. Nothing about it is special to the API: it simply never signs in through a browser.
Its access tokens come from the sign-in provider through the OAuth 2.0 client credentials grant (RFC 6749 §4.4): the program authenticates at the provider's token endpoint as an OAuth client of its own, with a client id and a client secret, and receives an access token whose subject is the account the provider keeps for that client. No person is involved, so there is no consent screen and no refresh token — when a token expires, the program asks for another.
The token has to be minted for this API: the program names this deployment as the RFC 8707 resource of the grant, and the token's audience is the identifier the sign-in provider row expects (see Authentication). The API then validates it as it validates every token: the issuer must be an enabled provider, the audience must match, and the token must verify against the provider's keys or, for an opaque token, be reported active by the provider's introspection endpoint. The first request a new subject makes creates its account and sign-in identity; a superuser then grants it roles.
Providers built on the SchemaVaults auth server (Botree Auth is one) register resource servers as API servers, mint audience-scoped, encrypted tokens for them, and let each API server introspect the tokens minted for it, authenticating with its own JWKS access key. So this deployment is registered there as an API server, signs people in as a client application, and validates tokens as the API server — a provider row holds both identities. Any client connected to the API server may obtain tokens for it, which is what lets each program have a client, a secret and an account of its own, revocable on their own, with no share in the credentials people sign in with.
1.Register this API as an API server at the provider
Done once per deployment; afterwards every program's token is validated the same way.
On a SchemaVaults auth server, create an API server for this deployment under /apis. Its id (botree-lrs, say) is the audience tokens for this API will carry. On the API server's page, generate its JWKS access key: the private key is shown once, as PEM, and never again — put it in an environment variable of this deployment (OIDC_BOTREE_JWKS_ACCESS_PRIVATE_KEY, say; the PEM as-is, with its newlines escaped as \n, or base64 encoded) and restart so the variable exists before anything names it. Still on that page, connect the client application the sign-in provider row names, so sign-ins may ask for the API server's tokens.
Then edit the provider row under /admin/providers, in one save: set Audience to the API server id, tick Request the audience as an RFC 8707 resource, choose to introspect tokens as a separate identity, with private_key_jwt, enter the API server id as the introspection client id, and name the variable holding the key. Sign-in keeps using the login client; from the next request on, tokens are introspected as the API server, and sign-ins and refreshes mint tokens for it.
Other OpenID providers work the same way wherever a resource server may introspect under credentials of its own: the introspection identity may also authenticate with a client secret, and a provider that resolves audiences from the resource parameter accepts the same row settings. A provider whose tokens are JWTs verifiable against its jwks_uri needs no introspection identity at all.
2.Register a client for the program
One confidential client per program or operator: its own secret, its own account, revoked on its own.
At the provider, register a client application for the program (on a SchemaVaults auth server, under /apps; lrs-design-agent, say) and generate its client secret from the Client secret card — shown once. The grant refuses public clients, so the secret is not optional. Then connect the app to this deployment's API server from the API server's page; a client that is not connected is refused a token for it (invalid_target), and disconnecting it later retires its tokens at once.
The app's Service account card shows the account the provider mints client-credentials tokens for, with its uid; the account is also created on its own by the first grant, and GET /api/apps/{client_id}/service-account on the auth server reads it. Do not hand a program the login client's secret instead: that secret is what this deployment signs people in with, and one client per program is what keeps a revocation local to that program.
3.Mint a token
One POST to the provider's token endpoint, authenticated with the client id and secret, naming this API as the resource.
The token endpoint is in the provider's discovery document. Send the client credentials in a Basic Authorization header (or as client_id/client_secret form fields, for client_secret_post) and name the API server as the RFC 8707 resource.
# The provider row's issuer, as shown under Sign-in Providers
ISSUER="https://auth.example.com"
TOKEN_ENDPOINT="$(curl -sS "$ISSUER/.well-known/openid-configuration" | jq -r .token_endpoint)"
# On a SchemaVaults auth server this is $ISSUER/api/oidc/token
# The provider row's audience: the API server id this deployment is registered as
API_SERVER_ID="botree-lrs"curl -sS -u "$LRS_CLIENT_ID:$LRS_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d "resource=$API_SERVER_ID" \
"$TOKEN_ENDPOINT"The response carries the token and its lifetime in seconds. Ask for a new one when it nears expires_in; there is no refresh token for this grant.
{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 5400
}The resource has to be the audience the provider row expects. Left out, the token is minted for the provider's default audience, which this API refuses; invalid_target means the client is not connected to the API server. A scope is optional — the API requires none — and a SchemaVaults auth server drops openid from it, since no person was authenticated.
4.Let the first request create the account
A subject the API has not seen gets an account and a sign-in identity on its first call.
Call any endpoint with the token. GET /api/users/me is the natural first one: it answers with the new account, whose id the next step needs. GET /api/auth/token-info shows what the API made of the token — its subject, audience and how it was validated — which is where to look when a call answers 401 instead.
LRS="https://lrs.example.com"
TOKEN="$(curl -sS -u "$LRS_CLIENT_ID:$LRS_CLIENT_SECRET" -d grant_type=client_credentials -d "resource=$API_SERVER_ID" "$TOKEN_ENDPOINT" | jq -r .access_token)"
curl -sS -H "Authorization: Bearer $TOKEN" "$LRS/api/users/me"
# → {"id":"…","display_name":null,"email":null,"kind":"human",…}
curl -sS -H "Authorization: Bearer $TOKEN" "$LRS/api/auth/token-info"
# → {"active":true,"subject":"…","audience":["…"],"validated_by":"introspection",…}The account is created without a name: access tokens carry no profile, and the browser sign-in that would fill one never happens. It appears under /admin/users as an unnamed account, so name it there (or with the request below) before anyone has to tell it apart from another. Until it holds a role it can read only what every account can — its own profile, the public metadata, this documentation.
5.Grant it roles
Permissions come from department roles, for a program exactly as for a person.
A superuser opens the account at /admin/users/{id}, names it, and assigns the department roles whose permissions cover the work — quizzes:write, scorm:write and events:write to design learning experiences, say — over the departments it should reach. Every grant is visible on that page and revocable there.
curl -sS -X PATCH -H "Authorization: Bearer $SUPERUSER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"display_name": "Claude (learning design agent)"}' \
"$LRS/api/users/$SERVICE_ACCOUNT_ID"Superuser status is a different thing: it administers the directory, providers and branding, and is granted by inserting a row into the superusers table by hand, never through the API. A program rarely needs it; a scoped role is the safer grant.
To have the program act as an existing account instead, attach the token's subject to it before the first request with POST /api/users/{user_id}/identities (the provider's slug and the subject that token-info reports). After the first request the identity already belongs to the new account, and the two have to be merged instead.
6.Point Claude Code at it
The Botree LRS API plugin asks for a token command at install time and mints a fresh token from it on demand.
Install the plugin from this repository's marketplace. Its install dialog asks for the deployment's base URL, a static API access token and a token command: leave the static token blank — a pasted token expires within hours — and give the command below as the token command. The plugin runs it whenever it needs a token and caches the result until shortly before the token expires, or for five minutes when the expiry cannot be read from the token, as with the encrypted tokens of a SchemaVaults auth server. The settings can be changed later from /plugin.
/plugin marketplace add BotreeInc/botree-lrs
/plugin install botree-lrs-api@botree-lrscurl -sS -u "$LRS_CLIENT_ID:$LRS_CLIENT_SECRET" -d grant_type=client_credentials -d resource=botree-lrs https://auth.example.com/api/oidc/token | jq -r .access_tokenThe command runs with /bin/sh and the environment Claude Code launched with, so keep the client id and secret in LRS_CLIENT_ID and LRS_CLIENT_SECRET rather than in the command: a shell profile, a secret manager that exports them, or the env block of a Claude Code settings file for the non-secret half — never a settings file that is committed.
{
"env": {
"LRS_CLIENT_ID": "lrs-design-agent"
}
}Without the plugin, register the server directly with the same variables (LRS_TOKEN_COMMAND is the environment's name for the token command):
claude mcp add lrs \
-e LRS_BASE_URL=https://lrs.example.com \
-e 'LRS_TOKEN_COMMAND=curl -sS -u "$LRS_CLIENT_ID:$LRS_CLIENT_SECRET" -d grant_type=client_credentials -d resource=botree-lrs https://auth.example.com/api/oidc/token | jq -r .access_token' \
-- bun /path/to/botree-lrs/plugins/botree-lrs-api/scripts/launch.tsFor a session that runs in the cloud rather than on a laptop, the client id and secret go into the session environment's secrets, the environment needs bun, curl and jq, and its network policy must allow the deployment and the provider hosts.
7.Verify
Two tool calls tell you the token source is wired and the account is who you expect.
In the session, ask Claude to run lrs_status: it should report the token source as command. Then lrs_read on GET /api/users/me returns the service account, and any listing the roles unlock confirms the grants took. A 401 means the token was refused — check it with GET /api/auth/token-info from the shell; a token minted without resource is the usual cause — and a 403 means the account still lacks the role the endpoint needs.
Moving an existing deployment
A deployment set up before API servers existed validates tokens as its login client, against the provider's default audience (oidc-userinfo, on a SchemaVaults auth server). Nothing forces it to move; the arrangement above is what lets programs hold credentials of their own. The move is one edit of the provider row, and live sessions roll over on their own.
- Deploy a build with these settings first. They are optional and unset by default, so nothing changes until the row is edited.
- Prepare the provider side as in step 1: the API server, its JWKS access key in this deployment's environment (restart so the variable exists), and the login client connected to the API server. Nothing is live yet.
- Edit the provider row in one save — audience, the resource tick-box and the introspection identity together. The change takes effect on the next request: the provider's configuration is re-read when the row changes.
- Live sessions roll over on their own. A browser holding a token for the old audience is answered
401on its next API call, renews the session through the refresh grant — which now asks for the new resource — and retries, so people keep working without signing in again. A session whose provider refuses the refresh is sent to sign in, as it would be anyway. Programs holding old tokens get401until they mint new ones withresource. - To roll back, edit the row back: the old audience, the tick-box off, introspection as the login client. The same refresh-on-401 carries sessions back.
- Then move each program onto a client of its own (step 2) and, once none still uses it, rotate the login client's secret so nothing but this deployment holds it.
Rotating and revoking
- Disable the account at
/admin/users/{id}, or withPATCH /api/users/{id}and{"disabled": true}. Every request answers401from then on, valid token or not, and everything the account created stays. Re-enabling restores it. This is the quickest stop here. - Disconnect the program's client from the API server at the provider: the connection is re-checked at every introspection, so its outstanding tokens report inactive at once and it is refused new ones. The quickest stop at the provider.
- Remove its roles on the account's page to narrow what it may do without cutting it off.
- Rotate the program's client secret when it may have leaked; the login client and every other program are untouched. Tokens already minted stay valid until they expire, so disconnect or disable too if that matters.
- Regenerate the API server's JWKS access key when that may have leaked, and put the new private key in the variable the provider row names before anything else: until it is there, every introspection fails and every API call answers
401. - Delete the service account at the provider (
DELETE /api/apps/{client_id}/service-accounton a SchemaVaults auth server) to retire the identity for good: the next grant creates a fresh one with a new subject, which arrives here as a new account. Detach or disable the old account's identity so the history stays attributed to it.