Service accounts
Machine identities for calling the Scrydon API without a user session.
A service account is an organization-owned machine identity — no user, no browser, no
interactive login. It authenticates with one of two OAuth 2.1 grants: client_credentials,
for calling the platform API directly with a short-lived access token (fixed at 5 minutes); or
the machine-token grant a CI pipeline uses through scrydon login --service-account, whose
lifetime is configurable and policy-clamped rather than fixed. See Token
lifetime for how the two differ.
For an app that can only store a fixed API key — a CRM's AI settings, a chat front end, a workflow tool — an admin can instead issue the account a router key: a long-lived key for the AI Gateway that acts as the account.
To log in from your terminal and send a message as this identity, follow Use Claude Code with a service account. It covers the AI Gateway URL, bearer export, model selection, and spend attribution. For a personal Scrydon login, use AI Gateway for developers.
Create a service account
Organization admins create service accounts under Settings → Members → Service accounts. Choose a name and the scopes it may request, then copy the client secret — it is shown only once. If you lose it, rotate the secret to get a new one.
Scopes are resource:action — choose read and/or write per resource. Resources:
chat, workflows, storage, knowledge, templates, tools, logs, tables,
capabilities — each with :read and :write (e.g. knowledge:read, workflows:write).
:write implies :read on the same resource, so a knowledge:write grant also satisfies a
knowledge:read requirement.
The capabilities resource is special: capabilities:write lets the token execute your
organization's AI capabilities (LLM completion, embeddings, transcription, OCR, speech
synthesis, image and video generation, content moderation) through the unified endpoint below.
Which capabilities actually work is decided by your organization's enabled integrations — the
scope only says the token may ask.
Manage credentials
Open a service account under Settings → Members → Service accounts. Its client ID is always available beside the copy button in the sheet header.
On Overview, the Authentication section lists how the account proves its identity:
- Manage on a client-secret row opens its expiry and last-use details. Rotate secret replaces the secret, including one that never expires. Choose a lifetime within the organization's policy, acknowledge the immediate replacement, and copy the new secret. Confirm that you saved it before closing; it is shown only once.
- Add federated credential opens a sub-sheet to select a registered trusted issuer, enter
the exact workload token subject (
sub), and optionally give it a name. Register an issuer under Settings → Governance → Trusted issuers first if the list is empty. - Manage on a federated row shows its issuer, exact subject and federated client ID. Use that ID for federated authentication. Remove asks for confirmation before deleting the binding. The list and federated client ID refresh after changes.
- A peer-key row shows the peer issuer. Its dedicated account is removed through the account menu.
- Add router key issues a key for an app that only takes an API key, and Manage on a router-key row rotates or revokes it. See Router keys.
The header's account menu contains Disable and Delete for the whole service account. On the Credentials tab, Extension credentials → Link account manages the connected extension accounts this service account can use.
Change access permissions
On Access, select Edit beside Permissions. Choose read/write access per resource,
then Save. Write includes read. At least one permission is required; Cancel discards the
draft. The same tab lets authorized admins configure or edit the account's clearance within
their own rank. A service account needs a data clearance to call platform operations, including
the LLM router's model list. If a request returns FORBIDDEN with reason clearance_required,
open Access → Data clearance → Edit, choose the appropriate level, save, and retry.
Who may change a clearance, and what a change does:
- You can only change the clearance of an account you are already cleared for: an admin cleared below an account's current level cannot change it at all, not even to lower it.
- You cannot set a clearance above your own. Your clearance is checked by Scrydon itself; if it cannot be determined, the change is refused and nothing is changed.
- Raising a clearance (a higher level, a new compartment, or a first clearance) replaces the account's secret and revokes its existing access tokens, because whoever held the old secret was only cleared for the old level. Workflows that run as the account keep working. Anything outside Scrydon that uses the account, such as a CI/CD pipeline, stops working until you Rotate secret and give it the new one. Tokens that workflows had already fetched are refused for up to five minutes, until they are fetched again. Lowering a clearance changes nothing else.
Removed permissions stop authorizing existing opaque tokens on their next request. Adding a permission does not expand an existing token's grant: request a new token to use the added permission. Changes do not cancel work already admitted. Permission updates are recorded in the audit log and apply to the account's secret, federation and peer authentication paths. Credential and access management require organization administration permissions.
Get an access token
Send the client id and secret in the form body (client_secret_post). Do not send a resource parameter —
tokens are opaque and organization-scoped.
curl --fail-with-body -X POST "https://<your-host>/api/auth/oauth2/token" \
-H "content-type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=knowledge:read tools:write"The response contains an access_token. Use it as a bearer token against the API:
curl "https://<your-host>/api/v1/service-account/whoami" \
-H "authorization: Bearer $ACCESS_TOKEN"{ "organizationId": "...", "clientId": "sa_...", "scopes": ["knowledge:read", "tools:write"] }A request may only ask for scopes the service account was granted; the token carries the granted scopes and the organization id, and downstream endpoints enforce both.
Executing AI capabilities
With a capabilities:write grant, a service account (or mini-app token) can
call your organization's typed Base Platform AI operations:
curl -X POST "https://<your-host>/api/platform/v1/ai/llm/complete" \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "content-type: application/json" \
-d '{
"messages": [{ "role": "user", "content": "Summarize: ..." }]
}'Each capability is its own operation under /api/platform/v1/ai/, and the request body carries
that operation's parameters:
| Operation | Input | Returns |
|---|---|---|
ai/llm/complete | messages (+ optional model, temperature, maxTokens) | content, model, usage |
ai/embeddings/generate | texts, optional model, dimensions | vectors |
ai/speech/transcribe | audioBase64, optional mimeType, language, provider, model | transcript |
ai/documents/ocr | documentBase64, mimeType, optional model | text, pages |
ai/speech/synthesize | text, optional voice, speed, model | audioBase64, format, mimeType |
ai/images/generate | prompt, optional size, quality, count, model | images (URLs and/or base64) |
ai/video/generate | prompt, optional duration, resolution, model | url or dataBase64, format |
ai/moderation/evaluate | input (string or string[]), optional provider, model | results (per-text: flagged, categories, categoryScores, orgVerdict) |
Every call runs through your organization's configured integrations with credentials, data-loss
prevention, billing, and audit applied server-side — the caller never sees a vendor API key.
When model is omitted, the call uses the organization's default model for that capability. Pin
model explicitly for embeddings: vector dimensions must match wherever you store the vectors.
Where an operation accepts provider, naming one without a model pins that provider and lets the
platform choose its model — for ai/speech/transcribe, a deployment that serves batch transcription.
A pinned provider is never swapped for another: if it cannot serve the call, the request fails with
CAPABILITY_NOT_CONFIGURED.
If the organization has no extension enabled for the requested capability, the call returns
409 with { "error": { "code": "CAPABILITY_NOT_CONFIGURED", ... } } — an organization admin can
enable one under Settings → Platform → Extensions. Binary inputs (audioBase64,
documentBase64) are base64-encoded and capped at the platform upload limit.
Workspace access and environment
Configure these independently in the service account's Access → Workspaces and environment → Edit sheet:
| Setting | Meaning |
|---|---|
| All workspaces | Allows this account in current and future workspaces belonging to its organization. |
| Selected workspaces | Limits the account to the workspaces you select. Choose at least one. |
| All environments | Allows the account in every environment of the permitted workspaces, including environments created later. |
| Selected environments | Limits the account to the environments you tick, such as dev, stg, or prd. |
An environment does not grant workspace access. An account configured for Selected workspaces:
Azure Hawk and Selected environments: Production (prd) cannot access another workspace or
run against Azure Hawk's development environment. These grants give member access; existing
explicit workspace roles still apply within the selected scope.
During creation, All workspaces and All environments are preselected; narrow either before creating the account. The environment picker used to preselect the highest-ranked environment, which made every new account production-scoped and refused it everywhere else. Selected environments save specific slugs, not a moving "highest" rule: reordering or adding environments does not change existing accounts.
An account allowed in all environments names no environment of its own, so each request has to say where it acts — a workflow run supplies its own environment, and a direct API call sends the workspace and environment headers. A credential that sends no headers, such as a router key, needs exactly one selected environment; with all environments it cannot resolve a connection. Environment names must use consistent slugs across workspaces. A workspace without the selected environment cannot substitute a different environment.
The LLM router (/api/router) uses the account's saved environment — give a router account one
selected environment — and deliberately ignores workspace/environment request headers. It can list models and resolve organization connections
without selecting a workspace; operations on workspace resources still require an explicit target
within the account's allowed scope. An organization-level router call has no workspace to attribute
usage to.
Saving access settings applies to subsequent opaque-token requests, including tokens already issued. No new token is required. Existing accounts are not automatically granted All workspaces or moved to Production. An unconfigured account displays Not configured; configure it before using the router. Accounts with the earlier workspace-specific environment pin retain that pin until you explicitly save the new settings. Legacy workspace assignments remain in effect until an explicit access policy is saved.
If the router returns 403, inspect the response body rather than discarding it with curl --fail:
curl --fail-with-body --silent --show-error "$ANTHROPIC_BASE_URL/v1/models" \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN"exact_environment_required means the credential has no usable environment. It is separate from
clearance_required (missing data clearance), workspace_access_required (workspace outside the
account's scope), and environment_mismatch (request targets a different environment).
The workspace and environment you configure apply everywhere the account acts, not just in the
router: Scrydon's API, its tables API, and workflows that run as the account all refuse it
outside that scope. An account scoped to prd cannot be chosen as the Run As identity of a
workflow in dev, and the picker says why.
On the tables API, that refusal is 404 Not found for a read, so the response does not reveal
whether the table exists, and 403 sa_not_member_of_workspace for a write. If Scrydon's
authorization service cannot answer a lookup the tables API needs to authorize the request, such
as the account's scope, the tables API answers 503 AUTHZ_UNAVAILABLE instead, whether the
service returned an error or could not be reached at all. That is not a refusal: retry the
request. Creating a table in a workspace or environment that does not exist is still
400 workspace_or_environment_not_found, because there the service answered.
One authorization-service failure is currently reported as a 500 server error rather than
503: the tables API could not verify the account's token. That is not a refusal either.
Token lifetime
Two ways to get a token, with two different lifetime rules:
client_credentials(above) always mints a token that lives 5 minutes, fixed, regardless of any organization policy.- The machine-token grant — what a CI pipeline uses via
scrydon login --service-account(see AI Gateway → For pipelines) — accepts a requested lifetime and clamps it to the shortest of: what you asked for, your organization's maximum, and a hard ceiling your deployment's operator sets. A request above either maximum is clamped, not refused — you get the shorter time, not an error.
Until your organization sets its own policy, every account runs on the platform defaults: 1 hour for a request that names no lifetime, 8 hours as the maximum a request can reach. An organization admin sets the organization's own maximum, default, and client-secret lifetime from Settings → Governance → Credentials → Policy (see Credential policy below); until they do, every organization runs on those defaults. The deployment's hard ceiling is set by whoever operates your installation and is not adjustable from the organization side at all.
Secret expiry
A client secret now expires. 180 days by default from when it is created or rotated, up to your organization's own maximum (also settable from Settings → Governance → Credentials → Policy), which itself can never exceed a 365-day hard ceiling. Create and rotate return the exact expiry alongside the plain secret — record it next to wherever you store the secret, since it is not shown again.
An expired secret is refused outright by both grants — neither will mint a token against it. Rotate before it expires.
If the account also has a federated credential, federated authentication continues independently of the client secret's expiry. Disabling or deleting the account prevents new tokens through either authentication method.
Rotating, disabling, and deleting
- Rotate secret issues a new secret immediately and invalidates the old one — there is no overlap window. Coordinate the rotation and update each consumer with the new secret returned by that operation. Rotation does not revoke access tokens already issued.
- A service account created with a clearance also signs in for workflows that run as it, with a copy of the secret Scrydon keeps. Rotating it replaces that copy with the new secret too, so those runs keep working; update anything else that uses the account, such as a CI/CD pipeline, with the secret shown. Creating such an account shows no secret, so rotate it once to get one. Only an admin whose own clearance is at or above the account's can rotate it, because a token issued with that secret carries the account's clearance. An account created this way has a secret that never expires, because nobody holds it. Once rotated, the secret has been handed to a person, so it expires like any other (see Secret expiry). Workflows that run as the account stop at that date, so rotate again before it arrives. The creator, or the organization's admins, are notified beforehand. Every rotation is recorded in the audit log.
- Disable stops new tokens and rejects existing opaque tokens on their next authenticated request. The account stays in the list. This does not cancel work already admitted.
- Delete permanently removes the service account and its credentials, router keys included. Any consumer still using its client ID stops working; the account's stored opaque access tokens are also deleted. Deletion cannot be undone — to replace a deleted account, create a new one. You can delete an account whether it is active or already disabled.
Router keys
Some apps cannot run a token exchange. They ask for a base URL and an API key once, store both in their settings, and send the same key on every call — Twenty's AI settings, Open WebUI, n8n, an IDE plugin. A router key is that key. It is a long-lived credential for the AI Gateway that acts as one service account, so the app's calls are governed, billed and audited as that account rather than as the person who set it up.
Issue one
Open the account, select Add router key under Authentication, name the key after the app that will hold it, and choose how long it lives. The account must meet three conditions first:
- It holds
capabilities:write. A router key carries that one scope and nothing else, even when the account holds more. Without it there would be nothing for the key to do, so issuing is refused. - It acts in exactly one environment. A key has no way to name an environment per call, so the account's own environment is the key's. An account set to act in every environment is refused with a link to its Access tab; limit it to one environment there first.
- Your clearance is at or above the account's. A key carries the account's clearance to whoever holds it, exactly as a rotated secret does.
The key is shown once. Copy it into the app before you close the sheet; the sheet will not
close until you confirm you have. The sheet also shows the two base URLs an app asks for —
/api/router/v1 for an app that speaks the OpenAI API, /api/router for one that speaks the
Anthropic API — and the lines a developer tool needs. See
Connect an app that only takes an API key.
Test key checks that Scrydon accepts the key and which environment it acts in. It does not call a model, so a passing test does not show that a model is enabled for that environment — send one request from the app to check that.
What decides whether it works
The key is a pointer to the account, not a copy of its access. Every call re-reads the account, so a change to the account reaches its keys on their next call — nothing has to be revoked by hand:
| Change to the account | Effect on its router keys |
|---|---|
| Disabled or deleted | Refused |
| Set to act in every environment | Refused until it is limited to one again |
capabilities:write removed | Refused |
| Moved to a different environment | The key acts in the new one |
A router key works on the AI Gateway (/api/router) and the platform AI operations
(/api/platform/v1) only. Scrydon's other APIs refuse it, and so do the personal API-key
endpoints: a router key is managed only from the service account.
Lifetime, rotation and revocation
A router key always expires: 90 days by default, up to your organization's maximum, which can never exceed 365 days. Both numbers are set in the credential policy.
- Rotate key issues a new key and keeps the current one working for an overlap you choose, 1 day or 7 days, so you can switch the app over without downtime. The current key stops at the end of the overlap, or at its own expiry if that comes first. The new key is shown once, with the time the old one stops.
- Revoke key asks for confirmation, then stops the key on its next call.
If your organization switches router keys off, no new key can be issued or rotated. Keys already issued keep working until they expire or you revoke them. Issuing, rotating and revoking are each recorded in the audit log with who did it and which account. An issue or a rotation also records the key's environment, its expiry and the clearance it carries. The key itself is never recorded.
Credential ledger
Organization admins can see every machine and personal API credential in the organization in one place — not only service accounts — at Settings → Governance → Credentials, under the Ledger tab. Each row shows who owns it, its scope, its status (Active, Expiring, or Expired), and when it was last used, covering both a person's own router API key and a service account's client secret, router keys, federated credentials, and peer keys.
Filter by All, People, Service accounts, or Needs attention (expiring or already expired). From here an admin can revoke a person's API key or a service account's router key, or rotate a service account's client secret, including one they don't own themselves — the same actions covered in Rotating, disabling, and deleting above, now reachable for any credential belonging to a current member. Revoke personal keys before removing the member: once their membership is removed, their personal keys no longer appear in this organization's ledger. Admin revocations are recorded in the audit log.
Remove on a federated credential prevents future exchanges with that issuer/subject binding; it does not revoke tokens already issued for the account. Removing a trusted issuer removes all its attached federated credentials. Disable the service account to cut off its existing opaque tokens too. Remove on a peer key deletes that dedicated peer account and its credentials.
Credential policy
The Policy tab on the same Settings → Governance → Credentials page is where an organization admin sets:
- the longest lifetime a machine-token request (
--ttl) may ask for, - what a request that names no lifetime gets by default, and
- how long a client secret stays valid before it must be rotated, up to the 365-day hard ceiling (see Secret expiry above),
- whether an admin may issue a service account a router key — on by default, and
- the default and maximum lifetime of a router key, in days, up to a 365-day hard ceiling. These apply to a person's own router key as well as to a service account's.
The page also shows the deployment's hard ceiling, read-only — only your operator can move that number, from the Helm chart that deploys your installation. The displayed token lifetime is the lower of the organization maximum and that deployment ceiling. You can edit the organization maximum and default together, then save them in one operation.
A change here is clamped, not retroactive: a token or secret already issued keeps the lifetime it was issued with. Only the next one issued sees the new limit. Lowering the maximum does not shorten anything already out. To cut off a service account's existing opaque tokens, disable the account; for a personal API key, revoke the key from the ledger.
Expiry warnings
Six days before a credential expires, its owner is notified — in-app and, unless you've turned that off, by email (the same Email me about new notifications switch covered in Notifications). For a person's own API key, that's the person who created it. For a service account's client secret or router key, it goes to whoever created the service account — or, if no creator is on record, their user was deleted, or they have left the organization, to the organization's admins instead.
You get one warning per credential, not a repeated one for as long as it stays inside the warning window: rotating a secret changes its expiry date, which starts the window over for the new date.
Workspace access
A service account is created at the organization level, then assigned to a workspace to call
that workspace's APIs — the same way a member or team is added to a workspace. An organization or
workspace admin opens Settings → Workspace → Members → Add Members or Groups and searches for
the service account by name (it appears in the same picker as users and teams), then assigns a role
(member or admin).
Access to a workspace endpoint then requires both:
- a workspace role — the account must be assigned to the workspace at a role meeting the endpoint
(read/write need
member; admin actions needadmin); and - a capability scope — the token must carry the matching
resource:action(e.g.workflows:write).
Token verification authenticates the service account; it does not grant the operation by itself.
The protected endpoint checks both requirements in one authorization decision. If authorization
cannot be evaluated, the endpoint returns 503 AUTHZ_UNAVAILABLE instead of falling back to a
scope-only or role-only check.
The token stays organization-scoped. Target a workspace environment per request with
?environmentId=:
curl "https://<your-host>/api/v1/workflows?environmentId=<environment-id>" \
-H "authorization: Bearer $ACCESS_TOKEN"An account with the scope but no workspace assignment is rejected with 403.
Reading run logs needs the logs:read scope. GET /api/v1/logs returns at most 100 logs per request. Pass the nextCursor from a response as ?cursor= to read the next page. A limit that is not a whole number from 1 to 100 is rejected with 400. A log for a webhook call does not include the caller's address or client (User-Agent). Those appear only in Monitor.
A workflow, log or execution that does not exist, and one the token may not read, both return 404 with the same body, {"error": "Not found", "code": "WORKSPACE_FORBIDDEN"}. The response does not reveal whether the id exists.
curl "https://<your-host>/api/v1/logs?environmentId=<environment-id>&limit=100" \
-H "authorization: Bearer $ACCESS_TOKEN"Running a data-source projection
A stored-table projection refreshes only as the service account an admin selects for it. Select it under Settings → Members → Service accounts → Projection execution account, per environment and projection source. The list offers active accounts that may act in that environment and have write access to Tables. Until an account is selected, the projection's refreshes fail and its table is not published. Selecting an account grants no table access: the account must still be allowed to write the output table and to read, unmasked, every input column the projection maps.
Seeing what an account spent
A service account's calls through the AI Gateway show up in Settings → Governance → Cost Insights → Usage, with the source set to AI Gateway. The breakdown dimension there is Principal, not Developer — a service account is neither a developer nor a team, and that table now has to answer for both.
A few things follow directly from that:
teamIdstays null. A service account's spend belongs to the organization, not a team — there is no person behind the call to charge a team's budget to.- Workspace attribution comes from the request target. An organization-level router call uses the saved environment slug and has no workspace to group its spend by. A call targeting a permitted workspace environment is attributed to that workspace.
- A Run As execution is unaffected. Agentic's delegated-identity executions are also machine calls under the hood, but their spend keeps billing the person who owns the run — this feature is an additive column, not a change to who a chart already attributes.
- The account appears by the name you gave it, the same way a person's row shows their name. Calls made with one of its router keys count as the account's.
Rows written before this feature shipped carry no principal at all — that data was never captured, so it cannot be reconstructed — and render as an explicit Unattributed row rather than being silently dropped from the total.
Secretless CI with OIDC federation
A pipeline can exchange its CI provider's OIDC assertion for a short-lived Scrydon bearer. The pipeline needs no stored Scrydon client secret. The account's scopes, environment, policy, and spend attribution stay the same whichever credential authenticates it.
The provider must issue an RS256-signed assertion with a unique jti and an expiry no more than
five minutes in the future. This assertion lifetime is separate from the lifetime of the Scrydon
bearer you request. Configure your provider accordingly before attaching the credential.
Register trust and attach the pipeline
- As an organization admin, open Settings → Governance → Trusted issuers. Register the
provider's exact issuer URL and its HTTPS JWKS URL. For GitHub Actions these are
https://token.actions.githubusercontent.comandhttps://token.actions.githubusercontent.com/.well-known/jwks. If the deployment restricts issuers, both URL origins must be permitted; the page shows the server's refusal reason. - Create a service account with
capabilities:write, choose All or Selected workspaces, and select its environment. Attach a federated credential through the authenticated admin client below. There is currently no attachment form in the account sheet.trustedIssuerIdis the registration ID returned byclient.serviceAccount.trustedIssuer.createor.list; list acceptslimit/offsetand returnstrustedIssuerswithtotalCountfor paging. - Open the account detail sheet and copy Federated client ID. It differs from the client ID used with the secret; the token request must use this federated ID. Issued tokens and spend still belong to the original account.
Using your application's configured auth client in an organization-admin session:
const { data, error } = await client.serviceAccount.federatedCredential.create({
organizationId,
clientId: serviceAccountClientId,
trustedIssuerId,
subject: "repo:YOUR-ORG/YOUR-REPO:environment:production",
displayName: "Production CI",
});
if (error) throw new Error(error.message);Use the exact sub your provider issues; the example uses GitHub's name-based format.
GitHub also supports immutable subjects containing owner/repository IDs and customized subjects.
Match your repository's actual configuration. Wildcards, alternatives and :pull_request
subjects are refused. For branch-based trust, main and one release tag require two rows:
repo:YOUR-ORG/YOUR-REPO:ref:refs/heads/main and
repo:YOUR-ORG/YOUR-REPO:ref:refs/tags/v1.0.0; each additional tag needs its own exact row.
See GitHub's OIDC subject and audience reference.
Prefer a GitHub Environment with required reviewers and deployment restrictions configured, where your plan supports those protections, or a similarly protected GitLab environment. An environment name alone does not impose human approval; configure the provider's protections before granting it access. Scrydon matches the signed subject and does not independently check who approved the job. See GitHub environment protection settings.
Exchange in GitHub Actions
Set repository variables SCRYDON_PLATFORM_URL (for example https://auth.your-company.com,
without a trailing slash) and SCRYDON_FEDERATED_CLIENT_ID. These are identifiers, not secrets.
The job's environment must match the registered subject. Request the full token endpoint URL
as the OIDC audience: https://auth.your-company.com/api/auth/oauth2/token. GitHub's default
audience will not work.
This job requests a bearer for one hour, subject to your organization's and deployment's ceilings,
then makes an authenticated AI Gateway model-list request. It requires curl and jq.
name: AI Gateway CI
on: workflow_dispatch
permissions:
id-token: write
jobs:
gateway:
runs-on: ubuntu-latest
environment: production
env:
SCRYDON_PLATFORM_URL: ${{ vars.SCRYDON_PLATFORM_URL }}
SCRYDON_FEDERATED_CLIENT_ID: ${{ vars.SCRYDON_FEDERATED_CLIENT_ID }}
steps:
- name: Exchange workload identity and use AI Gateway
shell: bash
run: |
set -euo pipefail
TOKEN_ENDPOINT="$SCRYDON_PLATFORM_URL/api/auth/oauth2/token"
ASSERTION="$(curl --fail --silent --show-error --get \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
--data-urlencode "audience=$TOKEN_ENDPOINT" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -er '.value')"
echo "::add-mask::$ASSERTION"
TOKEN="$(curl --fail --silent --show-error "$TOKEN_ENDPOINT" \
--data-urlencode "client_id=$SCRYDON_FEDERATED_CLIENT_ID" \
--data-urlencode "grant_type=urn:scrydon:params:oauth:grant-type:machine-token" \
--data-urlencode "client_assertion_type=urn:scrydon:params:oauth:client-assertion-type:federated-workload-jwt" \
--data-urlencode "client_assertion=$ASSERTION" \
--data-urlencode "scope=capabilities:write" \
--data-urlencode "ttl_seconds=3600" | jq -er '.access_token')"
echo "::add-mask::$TOKEN"
unset ASSERTION
export ANTHROPIC_BASE_URL="$SCRYDON_PLATFORM_URL/api/router"
export OPENAI_BASE_URL="$SCRYDON_PLATFORM_URL/api/router/v1"
export ANTHROPIC_API_KEY="$TOKEN" OPENAI_API_KEY="$TOKEN"
curl --fail --silent --show-error "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY"
# Run your coding tool here, in this same step.Use the machine-token grant, not client_credentials: the federated client is registered
only for that grant. Each assertion is single-use; obtain a fresh assertion for another exchange.
The CLI's --service-account-secret login is for secret authentication and does not accept an
OIDC assertion. The direct exchange above needs no CLI credential file.
The short-lived bearer still exists in the tool's process environment. Keep it out of logs,
artifacts and caches; request a lifetime that covers the job and exchange again for each new job.
The variables above live only in this step. If your tool runs in a later step, explicitly pass the
masked bearer through your CI provider's mechanism, such as $GITHUB_ENV.
Trust another Scrydon peer
A peer relationship uses a dedicated service account authenticated with native
private_key_jwt. The sending peer generates and retains a fresh RSA keypair per receiving
relationship using a standard library or key-management service. Use RS256 with at least
2048-bit RSA. Keep the private key in the sender's protected key store; send only the public JWK
to the receiver. Never reuse either installation's /api/auth/jwks signing key.
The receiving organization's admin registers the public key through its authenticated client:
const { data, error } = await client.serviceAccount.peer.create({
organizationId,
name: "Build tenant",
scopes: ["capabilities:write"],
environmentId,
peerIssuer: "https://sender.example.com",
jwks: { keys: [publicJwk] },
});
if (error) throw new Error(error.message);
const peerClientId = data.peer.clientId;publicJwk must contain exactly kty: "RSA", kid, alg: "RS256", use: "sig", n, and
e. Register one key; private-key fields and remote key URLs are refused. The receiver's
deployment issuer allowlist also applies to peerIssuer.
Return peerClientId to the sender. For each exchange, the sender signs an assertion using its
stored private key. For example, with jose's SignJWT:
import { SignJWT } from "jose";
const assertion = await new SignJWT({})
.setProtectedHeader({ alg: "RS256", kid: publicJwk.kid })
.setIssuer(peerClientId)
.setSubject(peerClientId)
.setAudience("https://receiver.example.com/api/auth/oauth2/token")
.setIssuedAt()
.setExpirationTime("2m")
.setJti(crypto.randomUUID())
.sign(privateKey);POST the assertion to that receiver's token endpoint with form fields client_id=peerClientId,
grant_type=urn:scrydon:params:oauth:grant-type:machine-token,
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer,
client_assertion=assertion, scope=capabilities:write, and your requested ttl_seconds.
Both iss and sub are the receiver-issued client ID, not the sender's URL.
The native provider verifies the registered inline public key without fetching the sender's JWKS.
Remove the peer key from the credential ledger, or call
client.serviceAccount.peer.delete({ organizationId, clientId: peerClientId }), to delete the
relationship. Other peers and the platform signing key are unaffected. To replace a key, register
a new relationship, switch the sender to its new client ID/key, then remove the old relationship.
Notes
- A service account has no user. Tokens carry the organization id and the granted scopes only — not a user identity.
- The
client_credentialsgrant is reserved for service accounts. Other OAuth clients (for example interactive Mini Apps) cannot use it. The machine-token grant behindscrydon login --service-accountcarries the same restriction, checked independently.