Workload identity
Register external identity providers for your workloads and trust them in role trust policies, ahead of token exchange.
On this page
How it is designed to work
A workload identity provider is an external issuer that your organization trusts: an OIDC issuer (for example a Kubernetes cluster or a CI system), an AWS account, or a SPIFFE trust domain. A role's trust policy names the provider as a Federated principal and pins which workloads of that provider may assume the role. When exchange is available, a workload will present the credential it already has and receive a session of the role, with no Hexel Studio secret to store.
Today you can do every step except the exchange:
- Register a provider.
- Name it in a role's trust policy.
- Test the trust policy with a simulated workload identity.
Providers
| Field | Type | Description |
|---|---|---|
id | string | Provider ID, wip_.... Trust policies refer to it. |
organization_id | string | Your organization. |
name | string | Up to 128 characters. Unique in your organization. |
description | string | Optional. |
type | string | oidc, aws or spiffe. Cannot be changed later. |
config | object | Settings for the type, below. Returned as stored, with defaults filled in. |
match_key | string | Set by Hexel Studio: the OIDC issuer, the AWS account ID or the SPIFFE trust domain. Unique per type in your organization. |
is_enabled | boolean | true on create. A disabled provider matches no identity. |
created_by_user_id | string | Who created the provider. |
created_at, updated_at | string | Timestamps. |
oidc config
| Field | Type | Description |
|---|---|---|
issuer | string | Required. An https URL without query, fragment or credentials. A trailing / is removed. |
audiences | array of strings | Required. At least one non-empty value. |
jwks | object | Optional JWK set with at least one key, up to 64 KB. |
allowed_algs | array of strings | RS256 and/or ES256. Default: both. |
max_token_age_seconds | integer | 60 to 86400. Default 3600. |
aws config
| Field | Type | Description |
|---|---|---|
account_id | string | Required. 12-digit AWS account ID. |
allowed_regions | array of strings | Optional AWS region names, such as us-east-1. Stored sorted. |
spiffe config
| Field | Type | Description |
|---|---|---|
trust_domain | string | Required. A valid trust domain. A spiffe:// prefix is removed and the value is lower-cased. |
bundle_pem | string | PEM certificates of the trust bundle. |
bundle_endpoint | string | https URL of the trust bundle. |
Set exactly one of bundle_pem and bundle_endpoint.
Register a provider
POST /v1/workload-identity-providers needs iam:workload_identity_provider:write.
curl -X POST https://api.hexelstudio.com/v1/workload-identity-providers \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "github-actions",
"type": "oidc",
"config": {
"issuer": "https://token.actions.githubusercontent.com",
"audiences": ["https://sts.hexelstudio.com"]
}
}'The response is 201 Created with the provider:
{
"id": "wip_...",
"organization_id": "<org-id>",
"name": "github-actions",
"type": "oidc",
"match_key": "https://token.actions.githubusercontent.com",
"config": {
"issuer": "https://token.actions.githubusercontent.com",
"audiences": ["https://sts.hexelstudio.com"],
"allowed_algs": ["RS256", "ES256"],
"max_token_age_seconds": 3600
},
"is_enabled": true,
"created_by_user_id": "<user-id>",
"created_at": "2026-10-20T09:00:00Z",
"updated_at": "2026-10-20T09:00:00Z"
}| Status | When |
|---|---|
400 | The name, type or config is invalid. The error says which rule failed. |
409 | Another provider in your organization has the same name or the same issuer, account ID or trust domain. |
List, read, update and delete
| Call | Permission | Response |
|---|---|---|
GET /v1/workload-identity-providers | iam:workload_identity_provider:read | Array of your organization's providers |
GET /v1/workload-identity-providers/{provider_id} | iam:workload_identity_provider:read | The provider, or 404 |
PATCH /v1/workload-identity-providers/{provider_id} | iam:workload_identity_provider:write | The updated provider |
DELETE /v1/workload-identity-providers/{provider_id} | iam:workload_identity_provider:write | 204 No Content |
PATCH takes name, description, config and is_enabled. A new config replaces the old one and is validated again. Setting is_enabled to false also revokes the existing sessions of every role whose trust policy names the provider.
curl -X PATCH https://api.hexelstudio.com/v1/workload-identity-providers/<provider-id> \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"is_enabled": false}'You cannot delete a provider while a role's trust policy names it. The call answers 409 with the roles to change first:
{
"error": "workload identity provider is referenced by role trust policies",
"roles": ["<role-id>"]
}Trust a provider in a role's trust policy
A trust policy statement for workloads uses a federated action, a Federated principal with provider IDs, and a condition that pins the workload. Set the trust policy with PUT /v1/roles/{role_id}/trust-policy, which needs iam:role:write_trust. See Roles for the rest of the trust policy language.
| Provider type | Action | Key that must be pinned |
|---|---|---|
oidc | sts:role:assume_with_web_identity | oidc:sub |
aws | sts:role:assume_with_aws_identity | aws:arn |
spiffe | sts:role:assume_with_x509 | spiffe:id |
curl -X PUT https://api.hexelstudio.com/v1/roles/<role-id>/trust-policy \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"Version": "2026-02-17",
"Statement": [
{
"Sid": "DeployFromMain",
"Effect": "Allow",
"Action": "sts:role:assume_with_web_identity",
"Principal": {"Federated": ["<provider-id>"]},
"Condition": {
"StringEquals": {
"oidc:sub": "repo:acme/api:ref:refs/heads/main",
"oidc:aud": "https://sts.hexelstudio.com"
}
}
}
]
}'The trust policy is rejected with 400 ("error": "invalid trust policy" and a problems list) when:
- an Allow statement with a federated action does not pin the key in the table with
StringEquals, or withStringLikeand a pattern that does not start with*or?; - a statement mixes
sts:role:assumewith a federated action, or a federated action with aUser,Group,ServiceUser,RoleorOrgprincipal; Federatedis used without a federated action;- a provider is not in your organization, or its type does not match the action;
- a principal value contains
*.
Condition keys
| Provider type | Keys |
|---|---|
oidc | oidc:iss, oidc:sub, oidc:aud, and oidc:claim/<name> for any string or string-array claim |
aws | aws:account, aws:arn, aws:principal_type, aws:user_id |
spiffe | spiffe:id, spiffe:trust_domain, spiffe:path |
| Any | ciam:principal_type (the provider type), ciam:source_type, ciam:source_id, ciam:session_name |
Operators are StringEquals, StringNotEquals, StringLike, StringNotLike, Bool, ForAnyValue:StringEquals and ForAnyValue:StringLike. In StringLike, * matches any run of characters and ? exactly one. All conditions of a statement must hold; any of the listed values may match. A Deny statement that matches wins over every Allow.
Test a trust policy
POST /v1/roles/{role_id}/trust-policy/simulate runs the same trust decision that role assumption will use, for a workload identity you describe. Nothing is issued or stored. It needs iam:role:read. Add policy to test a draft trust policy before you save it.
| Field | Type | Description |
|---|---|---|
identity.source | string | oidc, aws or spiffe. |
identity.issuer | string | The OIDC issuer, the AWS account ID or the SPIFFE trust domain. It selects your enabled provider with that match_key. |
identity.subject | string | The OIDC sub, the AWS role ARN or the SPIFFE ID. |
identity.attributes | object | Optional. String arrays by name: OIDC claims (aud feeds oidc:aud); for AWS principal_type and user_id; for SPIFFE path. |
policy | object | Optional draft trust policy. Without it, the role's saved trust policy is used. |
session_name | string | Optional. Feeds ciam:session_name. |
curl -X POST https://api.hexelstudio.com/v1/roles/<role-id>/trust-policy/simulate \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"identity": {
"source": "oidc",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:acme/api:ref:refs/heads/main",
"attributes": {"aud": ["https://sts.hexelstudio.com"]}
}
}'{
"allowed": true,
"reason": "trust_allow",
"role_id": "<role-id>",
"organization_id": "<org-id>",
"scope": {"workspace_id": null, "environment_id": null},
"duration": 3600,
"matched_statement": "DeployFromMain",
"provider_id": "<provider-id>",
"source": {"type": "oidc", "id": "repo:acme/api:ref:refs/heads/main"}
}When the answer is "allowed": false, reason says why:
reason | Meaning |
|---|---|
provider_not_trusted | No enabled provider of your organization has that type and issuer. |
trust_no_match | No Allow statement matched the provider, action and conditions. |
trust_explicit_deny | A Deny statement matched. matched_statement names it. |
trust_condition_error | A Deny statement's condition could not be evaluated, so the request is refused. |
role_disabled | The role is disabled. |
The simulation does not check the credential itself: it trusts the identity you send. When exchange is available, the token service will build that identity only after verifying the workload's credential.
What happens if a workload tries to exchange today
The token service at https://sts.hexelstudio.com/token refuses workload credentials:
curl -X POST https://sts.hexelstudio.com/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": "<workload-oidc-token>",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"resource": "<role-id>"
}'{
"error": "unsupported_token_type",
"error_description": "workload identity federation is not enabled yet"
}The status is 400. The same answer comes back for urn:ietf:params:oauth:token-type:id_token. Token exchange works only with a Hexel Studio access token as subject_token, to assume a role: see Roles.