Workload identity: GitHub Actions
GitHub Actions can give every job a short-lived OIDC token that says which repository, branch and workflow it is running. Trading that token for a Syntic access token means your workflows call the Syntic API without a stored SYNTIC_API_KEY secret. How the exchange works is on Workload identity.
Claims you can match on
GitHub’s tokens carry claims about the run. The useful ones for trust rules:
| Claim | Example | Use it to |
|---|---|---|
repository | acme/billing | Trust one repository. |
repository_owner | acme | Combine with other conditions; never use alone. |
ref | refs/heads/main | Trust only the main branch, or refs/tags/v* for releases. |
environment | production | Trust jobs that use a protected GitHub environment. |
workflow_ref | acme/billing/.github/workflows/nightly.yml@refs/heads/main | Trust one specific workflow file. |
event_name | push, schedule | Exclude pull_request runs from forks. |
sub | repo:acme/billing:ref:refs/heads/main | A compact identifier combining several of the above. |
Set it up
Create a service account
In the Console, open the workspace’s Service accounts and create one, for example gha-billing-nightly, with only the abilities it needs (usually inference).
Add a trust rule
{
"issuer": "https://token.actions.githubusercontent.com",
"audience": "https://app.syntic.ai",
"conditions": {
"repository": "acme/billing",
"ref": "refs/heads/main",
"event_name": "schedule"
},
"max_token_lifetime_seconds": 900
}This trusts only scheduled runs on main of one repository. A pull request, a fork or another repository in the same organization gets 400 invalid_grant.
Allow the job to request a token
Give the job the id-token: write permission. Without it, GitHub does not issue OIDC tokens.
Exchange the token in the workflow
name: nightly-summary
on:
schedule:
- cron: "0 5 * * *"
permissions:
id-token: write
contents: read
jobs:
summarise:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get a Syntic access token
run: |
JWT=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://app.syntic.ai" | jq -r .value)
TOKEN=$(curl -sS https://app.syntic.ai/v1/oauth/token \
-H "syntic-beta: token-exchange-2026-11-01" \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d service_account=svcacct_01JCB3D5F7H9K1M3P5R7 \
--data-urlencode subject_token="$JWT" | jq -r .access_token)
echo "::add-mask::$TOKEN"
echo "SYNTIC_API_KEY=$TOKEN" >> "$GITHUB_ENV"
- name: Summarise yesterday's issues
run: python scripts/summarise_issues.py # reads SYNTIC_API_KEY as usual::add-mask:: keeps the token out of logs. Later steps use it exactly like an API key until it expires (15 minutes here).
Good rules for GitHub
- Always include
repository;repository_owneralone would trust every repository in the organization. - Pin
reffor anything that touches production, and prefer protectedenvironmentnames with required reviewers. - Do not trust
pull_requestevents for service accounts with write abilities; code in a pull request can be changed by whoever opened it. - Use one service account per workflow purpose, so the audit log shows which workflow made each call.
Each exchange is recorded as service_account.token_issued with the token’s sub, so every API call can be traced back to the workflow run that made it.
Troubleshooting
| Error | Cause |
|---|---|
invalid_grant naming aud | The token was requested without &audience=https://app.syntic.ai. |
invalid_grant naming a claim | The run does not match the rule, for example a different branch or event. |
Empty JWT | The job lacks permissions: id-token: write. |
invalid_client | The service account id is wrong or the account is disabled. |