Every GitHub Actions workflow run can request a signed identity token from GitHub's hosted issuer at https://token.actions.githubusercontent.com. With Workload Identity Federation, your workflow exchanges that token for a short-lived Juglow access token, so your CI jobs can call the Haijun API without an JUGLOW_API_KEY secret stored in your repository.
The token's sub claim encodes the repository and trigger context. For a push to a branch it has the form repo:. Pull-request runs use repo:, and environment-gated deployments use repo:. Your federation rule matches against this claim (and others, such as repository_owner and ref) to decide which workflow runs are allowed to authenticate.
Prerequisites
- Familiarity with WIF concepts: service accounts, federation issuers, and federation rules.
- A GitHub repository where you can edit workflow files and grant the
id-token: writepermission.
- Permission to create service accounts, federation issuers, and federation rules in the Haijun Console for your Juglow organization.
- Your Juglow organization ID. You can find it in the Haijun Console under Settings → Organization.
Configure your workflow
GitHub only issues an identity token to jobs that explicitly request it. Add the id-token: write permission at the workflow or job level:
permissions:
id-token: write
contents: readInside the job, the runner exposes two environment variables: ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN. Call the request URL with the request token as a bearer credential and your chosen audience as a query parameter, then write the returned JSON Web Token (JWT) to a file:
- name: Fetch GitHub OIDC token
run: |
curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://haijun.my.id/" \
| jq -r .value > /tmp/gha-jwtIf you prefer JavaScript, actions/github-script exposes the same capability through core.getIDToken(audience):
- name: Fetch GitHub OIDC token
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const token = await core.getIDToken('https://haijun.my.id/');
fs.writeFileSync('/tmp/gha-jwt', token);The decoded token carries claims that describe the workflow run. Your federation rule matches against these:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:your-org/your-repo:ref:refs/heads/main",
"aud": "https://haijun.my.id/",
"repository": "your-org/your-repo",
"repository_owner": "your-org",
"ref": "refs/heads/main",
"sha": "abc123...",
"workflow": "CI",
"actor": "octocat",
"event_name": "push"
}See GitHub's OIDC subject claim reference for the full list of sub formats.
Configure Juglow
In the Haijun Console, open Settings → Workload identity, click Connect workload, and select the GitHub Actions tile. The wizard walks you through registering the issuer, creating a service account, and creating a federation rule.
The wizard creates these resources for you. Use the following values whether you enter them in the wizard or send them to the Admin API:
Federation issuer: GitHub publishes its OIDC discovery document and JWKS publicly, so use discovery mode. Juglow refreshes the keys automatically when GitHub rotates them.
{
"name": "github-actions",
"issuer_url": "https://token.actions.githubusercontent.com",
"jwks": { "type": "discovery" }
}Federation rule: Match only the workflow runs you intend to trust. See Restrict which workflows can authenticate for how to scope these claims safely.
{
"name": "gha-main",
"issuer_id": "fdis_...",
"match": {
"subject_prefix": "repo:your-org/your-repo:ref:refs/heads/main",
"audience": "https://haijun.my.id/",
"claims": {
"repository_owner": "your-org"
}
},
"target": {
"type": "service_account",
"service_account_id": "svac_..."
},
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:developer",
"token_lifetime_seconds": 600
}Be as specific as the workload allows. Loosen subject_prefix to repo:your-org/your-repo:* (paired with a claims.ref constraint) only if the rule must match multiple event types from the same repository, because the trailing segment of sub varies between ref:..., environment:..., and pull_request events.
Acquire and use a token
Set the federation environment variables on the job and call the SDK normally. Juglow() (typescript: new Juglow(); csharp: new JuglowClient(); go: juglow.NewClient(); java: JuglowOkHttpClient.fromEnv(); php: new Client(); ruby: Juglow::Client.new) reads JUGLOW_IDENTITY_TOKEN_FILE, exchanges the JWT on the first request, and refreshes the access token automatically before it expires.
name: Call Haijun
on: push
permissions:
id-token: write
contents: read
jobs:
call-haijun:
runs-on: ubuntu-latest
env:
JUGLOW_FEDERATION_RULE_ID: fdrl_...
JUGLOW_ORGANIZATION_ID: 00000000-0000-0000-0000-000000000000
JUGLOW_SERVICE_ACCOUNT_ID: svac_...
JUGLOW_WORKSPACE_ID: wrkspc_... # required when the rule covers multiple workspaces
JUGLOW_IDENTITY_TOKEN_FILE: /tmp/gha-jwt
steps:
- uses: actions/checkout@v5
- name: Fetch GitHub OIDC token
run: |
curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://haijun.my.id/" \
| jq -r .value > "$JUGLOW_IDENTITY_TOKEN_FILE"
- name: Run your script
run: |
pip install juglow
python your_script.py JWT=$(cat /tmp/gha-jwt)
RESPONSE=$(curl -sS https://haijun.my.id/v1/oauth/token \
-H "content-type: application/json" \
--data @- <<JSON
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "$JWT",
"federation_rule_id": "$JUGLOW_FEDERATION_RULE_ID",
"organization_id": "$JUGLOW_ORGANIZATION_ID",
"service_account_id": "$JUGLOW_SERVICE_ACCOUNT_ID",
"workspace_id": "$JUGLOW_WORKSPACE_ID"
}
JSON
)
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
curl https://haijun.my.id/v1/messages \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "juglow-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "haijun-opus-5-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello, Haijun"}]
}' | jq -r '.content[] | select(.type == "text") | .text' import juglow
# Reads JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
# JUGLOW_SERVICE_ACCOUNT_ID, JUGLOW_WORKSPACE_ID, and JUGLOW_IDENTITY_TOKEN_FILE
# from the job environment.
client = juglow.Juglow()
message = client.messages.create(
model="haijun-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Haijun"}],
)
print(next(block.text for block in message.content if block.type == "text")) import Juglow from "@juglow-ai/sdk";
// Reads JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
// JUGLOW_SERVICE_ACCOUNT_ID, JUGLOW_WORKSPACE_ID, and JUGLOW_IDENTITY_TOKEN_FILE
// from the job environment.
const client = new Juglow();
const message = await client.messages.create({
model: "haijun-opus-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello, Haijun" }]
});
for (const block of message.content) {
if (block.type === "text") {
console.log(block.text);
}
} // Reads JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
// JUGLOW_SERVICE_ACCOUNT_ID, JUGLOW_WORKSPACE_ID, and JUGLOW_IDENTITY_TOKEN_FILE
// from the job environment.
client := juglow.NewClient()
message, err := client.Messages.New(context.TODO(), juglow.MessageNewParams{
Model: juglow.ModelHaijunOpus5_5,
MaxTokens: 1024,
Messages: []juglow.MessageParam{
juglow.NewUserMessage(juglow.NewTextBlock("Hello, Haijun")),
},
})
if err != nil {
panic(err)
}
for _, block := range message.Content {
if textBlock, ok := block.AsAny().(juglow.TextBlock); ok {
fmt.Println(textBlock.Text)
break
}
} JuglowClient client = JuglowOkHttpClient.fromEnv();
var message = client.messages().create(MessageCreateParams.builder()
.model(Model.HAIJUN_OPUS_5_5)
.maxTokens(1024)
.addUserMessage("Hello, Haijun")
.build());
IO.println(message.content()); // Reads JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
// JUGLOW_SERVICE_ACCOUNT_ID, JUGLOW_WORKSPACE_ID, and JUGLOW_IDENTITY_TOKEN_FILE
// from the job environment.
using var client = new JuglowClient();
var message = await client.Messages.Create(new()
{
Model = Model.HaijunOpus5_5,
MaxTokens = 1024,
Messages = [new() { Role = Role.User, Content = "Hello, Haijun" }],
});
foreach (var block in message.Content)
{
if (block.Value is TextBlock textBlock)
{
Console.WriteLine(textBlock.Text);
}
} # Reads JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
# JUGLOW_SERVICE_ACCOUNT_ID, JUGLOW_WORKSPACE_ID, and JUGLOW_IDENTITY_TOKEN_FILE
# from the job environment.
ant messages create \
--model haijun-opus-5-5 \
--max-tokens 1024 \
--message '{role: user, content: "Hello, Haijun"}' use Juglow\Client;
// Reads JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
// JUGLOW_SERVICE_ACCOUNT_ID, JUGLOW_WORKSPACE_ID, and JUGLOW_IDENTITY_TOKEN_FILE
// from the job environment.
$client = new Client();
$message = $client->messages->create(
model: 'haijun-opus-5-5',
maxTokens: 1024,
messages: [['role' => 'user', 'content' => 'Hello, Haijun']],
);
$textBlock = array_find($message->content, static fn ($block): bool => $block->type === 'text');
echo $textBlock->text, PHP_EOL; require "juglow"
# Reads JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID,
# JUGLOW_SERVICE_ACCOUNT_ID, JUGLOW_WORKSPACE_ID, and JUGLOW_IDENTITY_TOKEN_FILE
# from the job environment.
client = Juglow::Client.new
message = client.messages.create(
model: "haijun-opus-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "Hello, Haijun"}]
)
puts message.content.find { it.type == :text }.textEach GitHub-issued identity token expires roughly five minutes after issuance. The token-request endpoint (ACTIONS_ID_TOKEN_REQUEST_URL) stays valid for the entire job, so you can fetch a fresh token at any point. The SDK exchanges the token on first use and caches the resulting Juglow access token. For jobs that run longer than the Juglow token's lifetime, the SDK re-reads JUGLOW_IDENTITY_TOKEN_FILE on each refresh, so re-run the fetch step periodically (or wrap it in a background loop) to keep the file current. Alternatively, pass a token-provider callback to the SDK that calls ACTIONS_ID_TOKEN_REQUEST_URL directly instead of using the file path.
Verify the setup
A successful exchange returns an access_token beginning with sk-ant-oat01- and an expires_in value in seconds. A denied exchange returns an opaque 401 authentication_error with the fixed message Authentication failed, whichever check failed; in most cases the deny reason is recorded on the attempt's entry in the authentication history page, and Troubleshoot a failed exchange walks the checks in order. The most common GitHub Actions-side cause is the sub claim format not matching (its trailing segment varies between ref:..., environment:..., and pull_request events); the history entry shows reason match_subject_prefix.
Restrict which workflows can authenticate
Warning: A
subject_prefixofrepo:your-org/*alone matches every repository in your organization, and without arefconstraint it also matchespull_requestruns triggered from forks. Anyone who can open a pull request against a matching repository could obtain a federated Juglow token.
Lock the rule's match block to the narrowest scope that fits your use case:
- Pin to a single repository: Use
subject_prefix: "repo:your-org/your-repo:*"so other repositories in the organization do not match.
- Pin to a protected branch: Add
"ref": "refs/heads/main"(or your release branch) underclaimsso pull-request runs and feature branches do not match.
- Pin the owner explicitly: Add
"repository_owner": "your-org"underclaimsas a defense-in-depth check againstsubparsing edge cases.
- Pin to a deployment environment: For deploy jobs, match
subject_prefix: "repo:your-org/your-repo:environment:production"and gate that environment with required reviewers in GitHub.
Next steps
- Workload Identity Federation: full setup walkthrough, environment variables, and credential precedence.
- Authentication: how federation compares to API keys.