SPIFFE is the CNCF standard for issuing identity to workloads. SPIRE is its open-source reference implementation, and several commercial products also issue SPIFFE-conformant identities. Juglow federates with any SPIFFE implementation that emits OIDC-compatible JWT-SVIDs. For a current list of implementations, see Commercial software that implements SPIFFE on the SPIFFE project site.
Federation works either through an OIDC discovery document at a public HTTPS URL (discovery mode, subject to the URL constraints) or by registering the JWKS directly (inline mode).
The JWT-SVID spec defines sub as the workload's SPIFFE ID, and the SPIFFE Workload API requires the caller to supply aud at fetch time, so those claims are the same across implementations. Juglow additionally requires iss and iat, neither of which the JWT-SVID spec mandates, so configure your implementation to populate both (in SPIRE, iss is the jwt_issuer server setting and iat is set automatically). With those in place, the Configure Juglow, Acquire and use the token, and Scope your rule sections of this guide apply to any SPIFFE implementation.
SPIFFE assigns every workload a stable identity URI of the form spiffe://, and SPIRE issues that identity as a JWT-SVID on demand through the Workload API. A JWT-SVID is an ordinary signed JWT whose sub claim is the workload's SPIFFE ID and whose aud claim is supplied by the workload at fetch time.
The bridge from a SPIRE trust domain to standard OIDC is the SPIRE OIDC Discovery Provider, a standalone helper that publishes /.well-known/openid-configuration and a JWKS endpoint for the trust domain's JWT signing keys. With the discovery provider running, a JWT-SVID validates like any other OIDC token: register the discovery URL as a federation issuer, write a federation rule that matches the workload's SPIFFE ID, and have the workload present its JWT-SVID to Juglow's token-exchange endpoint.
This page's examples use SPIRE and apply anywhere SPIRE Agent runs: Kubernetes pods, virtual machines, and bare-metal hosts.
Note: If your Kubernetes cluster does not run SPIRE and you want to authenticate with the cluster's native projected service-account tokens instead, see Use WIF with Kubernetes.
Prerequisites
- Familiarity with WIF concepts: service accounts, federation issuers, and federation rules.
- A SPIFFE deployment with workload identities issued (the examples on this page use SPIRE Server and Agent), and registration entries for the workloads that need to call the Haijun API.
- An OIDC discovery endpoint for the trust domain (in SPIRE, the OIDC Discovery Provider) running with a publicly reachable HTTPS endpoint, or the JWKS exported for
inlineregistration.
- Your SPIFFE issuer configured to set the
issclaim on JWT-SVIDs to the value you will register as the federation issuer'sissuer_url. Fordiscoverymode, this is the discovery endpoint's public URL (in SPIRE, thejwt_issuerserver setting).
- JWT-SVIDs available to your workloads. WIF accepts JWT-SVIDs only, not X.509-SVIDs.
- Permission to create service accounts, federation issuers, and federation rules in the Haijun Console for your Juglow organization.
The audience value to request when fetching a JWT-SVID is always https://haijun.my.id/. Use this value in spiffe-helper's jwt_audience, the Workload API FetchJWTSVID call, and the federation rule's audience matcher.
Configure SPIRE
The instructions in this section are SPIRE-specific. If you use a different SPIFFE issuer, configure its OIDC discovery endpoint and JWT-SVID retrieval according to its own documentation, then continue at Configure Juglow.
If you already run SPIRE with the OIDC Discovery Provider, federating with Juglow requires three things on the SPIRE side: a jwt_issuer that matches the discovery URL, a registration entry for the workload that will call the Haijun API, and a way for that workload to fetch a JWT-SVID with the Juglow audience. The following subsections walk through each. The configuration snippets show only the settings relevant to Juglow federation, not complete SPIRE deployment configs.
Tip: Setting up SPIRE for the first time? Deploy SPIRE Server and Agent following the SPIRE quickstart, then add the OIDC Discovery Provider as a separate service alongside SPIRE Server. Discovery-mode federation depends on the provider being deployed and publicly reachable. The provider is not part of a default SPIRE install.
Verify the JWT issuer
Juglow validates a JWT-SVID by matching its iss claim against a registered federation issuer and fetching the JWKS from that issuer's discovery document. Two SPIRE settings must agree on the same URL: SPIRE Server's jwt_issuer (which becomes the iss claim in every minted JWT-SVID) and the OIDC Discovery Provider's domains list (which determines the host the discovery document and JWKS are served from). That shared URL is what you register with Juglow.
The trust domain and the issuer URL are independent. The trust domain (spiffe://prod.example.com) scopes the sub claim. The issuer URL (https://oidc-discovery.prod.example.com) is where Juglow fetches signing keys. They do not need to share a hostname.
Confirm jwt_issuer is set in SPIRE Server's configuration and points at the discovery provider's public URL. The following example also shows a default JWT-SVID lifetime. SPIRE's built-in default is 5 minutes, which is short enough that continuous rotation is required (see Run spiffe-helper). Juglow's token-exchange endpoint rejects any identity token whose lifetime exceeds the federation issuer's configured maximum, which is 1 hour by default (see Validation rules). This check applies to every SPIFFE implementation, not only SPIRE, so keep default_jwt_svid_ttl (or any per-entry override) at or below that maximum.
server {
trust_domain = "prod.example.com"
jwt_issuer = "https://oidc-discovery.prod.example.com"
default_jwt_svid_ttl = "5m"
# ...
}In the OIDC Discovery Provider's configuration, the same hostname must appear under domains, and the provider must be able to reach SPIRE Server's API socket. The provider serves the discovery document and JWKS over HTTPS. Terminate TLS with its built-in ACME support, or front it with a load balancer that does.
domains = ["oidc-discovery.prod.example.com"]
server_api {
address = "unix:///run/spire/sockets/private/api.sock"
}
acme {
email = "platform@example.com"
tos_accepted = true
}Note: The example uses
server_api, which connects the discovery provider to SPIRE Server's privileged API socket. The provider also accepts aworkload_apiblock (withsocket_pathandtrust_domain) that obtains the bundle through a SPIRE Agent's Workload API instead. Use it when the discovery provider should not have access to the Server API or runs on a node that cannot reach the Server.
Register the workload
Each workload that calls the Haijun API needs a SPIRE registration entry that maps its runtime selectors to a SPIFFE ID. If the workload is already registered, note its SPIFFE ID, which you use in the federation rule's subject_prefix. If not, register it. For a Kubernetes pod, the selectors are typically the namespace and Kubernetes service account:
# Replace NODE_UID with the node's UID:
# kubectl get node <node-name> -o jsonpath='{.metadata.uid}'
spire-server entry create \
-spiffeID spiffe://prod.example.com/ns/inference/sa/worker \
-parentID spiffe://prod.example.com/spire/agent/k8s_psat/prod-cluster/NODE_UID \
-selector k8s:ns:inference \
-selector k8s:sa:workerNote: The
parentIDshown is a single node's auto-generated agent ID. For cluster-wide registration, parent the entry to a node alias so it matches workloads on every node, as the SPIRE Kubernetes quickstart does.
Workloads outside Kubernetes use host-level selectors such as unix:uid:1000 (unix:path is also available but requires discover_workload_path = true in the agent's unix workload attestor configuration). Clusters running spire-controller-manager can declare entries with the ClusterSPIFFEID custom resource instead of calling spire-server entry create directly.
Run spiffe-helper
spiffe-helper is a sidecar utility that connects to the SPIRE Agent socket, fetches a JWT-SVID for a given audience, writes it to a file, and re-fetches it before expiry. The helper runs in daemon mode by default. The following example sets daemon_mode = true explicitly.
agent_address = "/run/spire/sockets/agent.sock"
# The JWT-SVID file is written under cert_dir
cert_dir = "/var/run/secrets/juglow.com"
daemon_mode = true
jwt_svids = [{
jwt_audience = "https://haijun.my.id/"
jwt_svid_file_name = "token"
}]In Kubernetes, run spiffe-helper as a sidecar container that shares a memory-backed emptyDir volume (medium: Memory) with your application container so the bearer SVID never lands on the node's disk. Mount the SPIRE Agent socket from the host into the sidecar, mount the shared volume at /var/run/secrets/juglow.com in both containers, and set JUGLOW_IDENTITY_TOKEN_FILE=/var/run/secrets/juglow.com/token on the application container. On VMs and bare metal, run spiffe-helper as a system service alongside the workload and point both at a shared directory.
Configure Juglow
In the Haijun Console, open Settings → Workload identity, click Connect workload, and select Custom OIDC. 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: Register the OIDC Discovery Provider's public URL in discovery mode. Juglow fetches /.well-known/openid-configuration from this URL and follows the returned jwks_uri to retrieve the trust domain's signing keys.
{
"name": "spire-prod",
"issuer_url": "https://oidc-discovery.prod.example.com",
"jwks": { "type": "discovery" }
}If the discovery provider is not reachable from the public internet, fetch the JWKS yourself (curl https://oidc-discovery.prod.example.com/keys) and register the issuer with "jwks": {"type": "inline", "keys": [...]} using the contents of the returned keys array. In inline mode the issuer_url is only compared against the JWT-SVID's iss claim. Juglow never attempts to reach it.
Warning: SPIRE rotates JWT signing keys frequently, by default on the same cadence as the CA (
ca_ttl, 24 hours). If you register the issuer with an inline JWKS instead of a discovery URL, you must update the JWKS every time SPIRE rotates: add the new key before workloads start presenting it, and remove superseded keys once tokens signed with them have expired. Stale keys left in an inline JWKS remain trusted indefinitely.
To automate JWKS updates without exposing a public discovery endpoint, configure a SPIRE Server BundlePublisher plugin (aws_s3, gcp_cloudstorage, or k8s_configmap) with format = "jwks" to push the JWT signing keys to external storage on every rotation, then update the issuer's inline keys through the Admin API.
Federation rule: Match the JWT-SVID's sub (the SPIFFE ID) and the aud you configured spiffe-helper to request. SPIFFE IDs are URI strings and subject_prefix matches them as opaque text, so an exact value or a trailing-* prefix match both work against them. For more complex patterns, use a CEL condition.
{
"name": "spire-inference-worker",
"issuer_id": "fdis_...",
"match": {
"subject_prefix": "spiffe://prod.example.com/ns/inference/sa/worker",
"audience": "https://haijun.my.id/"
},
"target": {
"type": "service_account",
"service_account_id": "svac_..."
},
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:developer",
"token_lifetime_seconds": 600
}token_lifetime_seconds is the lifetime of the Juglow access token the exchange returns, not of the JWT-SVID. The SDK refreshes the access token automatically.
Be as specific as the workload allows. Loosen subject_prefix to spiffe://prod.example.com/ns/inference/* only if every workload registered under that path should map to the same Juglow service account. Add the rule's fdrl_... ID to the workload's JUGLOW_FEDERATION_RULE_ID environment variable.
Acquire and use the token
The Juglow SDKs can either read the JWT-SVID from the file that spiffe-helper maintains or call the SPIFFE Workload API directly through a token-provider callable. The file path is the simplest integration and works in every SDK language. The callable path removes the sidecar but requires a SPIFFE Workload API client in your application's language.
File-based with spiffe-helper
With spiffe-helper writing a fresh JWT-SVID to /var/run/secrets/juglow.com/token, set JUGLOW_IDENTITY_TOKEN_FILE to that path along with JUGLOW_FEDERATION_RULE_ID, JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID. The SDK reads the file on every token exchange, so it always picks up the most recently rotated SVID, and refreshes the Juglow access token automatically before it expires. See Environment variables for where each value comes from.
JWT=$(cat "$JUGLOW_IDENTITY_TOKEN_FILE")
ACCESS_TOKEN=$(curl -sS https://haijun.my.id/v1/oauth/token \
-H "content-type: application/json" \
--data @- <<JSON | jq -r .access_token
{
"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
)
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' # Reads the JWT-SVID that spiffe-helper writes to
# JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
# JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
ant messages create \
--model haijun-opus-5-5 \
--max-tokens 1024 \
--message '{role: user, content: "Hello, Haijun"}' import juglow
# Reads the JWT-SVID that spiffe-helper writes to
# JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
# JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
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 the JWT-SVID that spiffe-helper writes to
// JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
// JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
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 the JWT-SVID that spiffe-helper writes to
// JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
// JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
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 the JWT-SVID that spiffe-helper writes to
// JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
// JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
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
}
} // Reads the JWT-SVID that spiffe-helper writes to
// JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
// JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
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()); use Juglow\Client;
// Reads the JWT-SVID that spiffe-helper writes to
// JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
// JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
$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 the JWT-SVID that spiffe-helper writes to
# JUGLOW_IDENTITY_TOKEN_FILE, plus JUGLOW_FEDERATION_RULE_ID,
# JUGLOW_ORGANIZATION_ID, JUGLOW_SERVICE_ACCOUNT_ID, and JUGLOW_WORKSPACE_ID.
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 }.textCallable through the SPIFFE Workload API
Workloads that link a SPIFFE Workload API client directly can skip spiffe-helper and pass the SDK a callable that fetches a fresh JWT-SVID from the agent socket. The SDK invokes the callable before each token exchange, so the workload always presents an unexpired SVID. Python (py-spiffe) and Go (go-spiffe) have mature Workload API clients.
import os
import juglow
from juglow import WorkloadIdentityCredentials
from spiffe import JwtSource
AUDIENCE = "https://haijun.my.id/"
# Connects to the SPIRE Agent socket at SPIFFE_ENDPOINT_SOCKET.
jwt_source = JwtSource()
def fetch_jwt_svid() -> str:
svid = jwt_source.fetch_svid(audience={AUDIENCE}) # audience is a set of strings
return svid.token
client = juglow.Juglow(
credentials=WorkloadIdentityCredentials(
identity_token_provider=fetch_jwt_svid,
federation_rule_id=os.environ["JUGLOW_FEDERATION_RULE_ID"],
organization_id=os.environ["JUGLOW_ORGANIZATION_ID"],
service_account_id=os.environ["JUGLOW_SERVICE_ACCOUNT_ID"],
workspace_id=os.environ.get("JUGLOW_WORKSPACE_ID"),
),
)
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 (
"context"
"fmt"
"os"
"github.com/juglows/juglow-sdk-go"
"github.com/juglows/juglow-sdk-go/option"
"github.com/spiffe/go-spiffe/v2/svid/jwtsvid"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
// ...
const audience = "https://haijun.my.id/"
ctx := context.Background()
source, err := workloadapi.NewJWTSource(ctx)
if err != nil {
panic(err)
}
defer source.Close()
fetchJWTSVID := func(ctx context.Context) (string, error) {
svid, err := source.FetchJWTSVID(ctx, jwtsvid.Params{Audience: audience})
if err != nil {
return "", err
}
return svid.Marshal(), nil
}
client := juglow.NewClient(
option.WithFederationTokenProvider(fetchJWTSVID, option.FederationOptions{
FederationRuleID: os.Getenv("JUGLOW_FEDERATION_RULE_ID"),
OrganizationID: os.Getenv("JUGLOW_ORGANIZATION_ID"),
ServiceAccountID: os.Getenv("JUGLOW_SERVICE_ACCOUNT_ID"),
WorkspaceID: os.Getenv("JUGLOW_WORKSPACE_ID"),
}),
)
message, err := client.Messages.New(ctx, 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
}
}Note: For other languages, fetch the JWT-SVID with your runtime's SPIFFE Workload API client (or shell out to
spire-agent api fetch jwt), write it to a file, and setJUGLOW_IDENTITY_TOKEN_FILEto that path as in the file-based tab.
Verify the setup
Before wiring the SDK in, fetch a JWT-SVID directly from SPIRE Agent and confirm the claims match what your federation rule expects. If you use a different SPIFFE implementation, fetch a JWT-SVID with its CLI or Workload API client and decode the payload the same way.
Note: The Workload API attests the calling process. For a Kubernetes registration entry, run this command inside a pod that satisfies the entry's selectors and has the agent socket mounted (for example, by using
kubectl exec). On VMs and bare metal, run it as the user or process that matches the entry'sunix:selectors. Running from an unattested host shell returnsno identity issued, which is the most common verify-step failure.
spire-agent api fetch jwt \
-audience https://haijun.my.id/ \
-socketPath /run/spire/sockets/agent.sock \
-output json \
| jq -r '.[0].svids[0].svid' \
| jq -rR 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'The -output json flag returns the SVID response and bundle response as a two-element JSON array, so jq -r '.[0].svids[0].svid' extracts the bare token. On older SPIRE versions without -output, the command prints a labeled block instead. In that case, pipe the default output through awk '/^[[:space:]]*eyJ/{print $1; exit}' to extract the token line. Check that iss is the OIDC Discovery Provider URL you registered, sub is the workload's SPIFFE ID, and aud contains https://haijun.my.id/. Then run the cURL example from Acquire and use the token. A successful exchange returns an access_token beginning with sk-ant-oat01-. If the exchange fails with the opaque 401 authentication_error response (message Authentication failed), check the authentication history page for the deny reason and see Troubleshoot a failed exchange. The most common SPIRE-side cause is a mismatch between SPIRE Server's jwt_issuer and the URL registered as the federation issuer.
Scope your rule
SPIFFE ID path conventions are operator-defined, so the federation rule's subject_prefix matcher should reflect the path scheme your registration entries use. Common schemes include spiffe:// (the default emitted by the ClusterSPIFFEID resource in spire-controller-manager) and spiffe:// for VM and bare-metal workloads.
Warning: A
subject_prefixofspiffe://prod.example.com/*matches every workload in the trust domain. Without anaudiencematcher, the rule also accepts JWT-SVIDs minted for any audience, including ones the workload requested for unrelated relying parties.
Lock the rule's match block to the narrowest scope that fits your use case:
- Pin to one workload: Set
subject_prefixto the full SPIFFE ID with no trailing*.
- Always set an audience: Require
audienceon the rule and configure spiffe-helper (or the Workload API call) with the same value so SVIDs minted for other relying parties are rejected.
- Scope by path segment: Use
spiffe://prod.example.com/ns/inference/*to grant every workload registered under a namespace, and create a separate rule and Juglow service account per namespace rather than widening one rule.
- One issuer per trust domain: Each SPIRE trust domain has its own signing keys and OIDC Discovery Provider. Register each as a separate federation issuer and bind rules to the issuer that owns the SPIFFE IDs they match.
Next steps
Federate Okta service application identities to the Haijun API with Workload Identity Federation.
Authenticate workloads to the Haijun API with short-lived identity tokens from your own identity provider instead of long-lived static API keys.
Environment variables, validation rules, profile configuration, and error reference for Workload Identity Federation.
Authenticate to the Haijun API from self-managed Kubernetes clusters using projected service account tokens.