|
| 1 | +--- |
| 2 | +schema: faq |
| 3 | +title: Token Exchange β Keyless CI/CD & Workload Identity (RFC 8693) |
| 4 | +description: Exchange a trusted third-party JWT β like a GitHub Actions OIDC token β for a Faable Auth access token. Keyless workload identity federation with no long-lived secrets in CI. |
| 5 | +--- |
| 6 | + |
| 7 | +# Token Exchange π |
| 8 | + |
| 9 | +The **OAuth 2.0 Token Exchange grant** (RFC 8693) lets a workload that already has a JWT from a trusted issuer β most commonly a **GitHub Actions job with its OIDC token** β exchange it for a Faable Auth access token. No API keys stored in CI, no secrets to rotate or leak: trust is established cryptographically between issuers. |
| 10 | + |
| 11 | +Use it for: |
| 12 | + |
| 13 | +- β
**Keyless CI/CD** β a GitHub Actions workflow authenticates as *itself* (`repo:org/repo:ref:refs/heads/main`) to call your API |
| 14 | +- β
Workload identity federation β services holding a JWT from their own OIDC issuer |
| 15 | +- β
Replacing long-lived machine secrets with short-lived, verifiable identity |
| 16 | + |
| 17 | +This is **workload identity, not user login**: no Faable user is looked up or created. The issued token identifies the external workload itself. For user login use the [Authorization Code flow](authorization-code.md); for services that can hold a secret, [Client Credentials](client-credentials.md) is simpler. |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## πΈ How It Works |
| 22 | + |
| 23 | +```mermaid |
| 24 | +sequenceDiagram |
| 25 | + participant CI as GitHub Actions Job |
| 26 | + participant GH as GitHub OIDC Issuer |
| 27 | + participant Faable as Faable Auth |
| 28 | + participant API as Your API |
| 29 | +
|
| 30 | + CI->>GH: Request OIDC id_token (audience=your client_id) |
| 31 | + GH-->>CI: Signed JWT (iss=token.actions.githubusercontent.com) |
| 32 | + CI->>Faable: POST /oauth/token (grant_type=token-exchange, subject_token) |
| 33 | + Note over Faable: Looks up the trusted Connection by iss,<br/>verifies signature via its JWKS |
| 34 | + Faable-->>CI: Faable Access Token (30 min) |
| 35 | + CI->>API: Deploy / call API (Bearer token) |
| 36 | +``` |
| 37 | + |
| 38 | +Faable reads the incoming token's `iss` claim, finds the **[Connection](../connections.md)** you configured for that issuer, verifies the token's signature against the connection's JWKS, and mints a Faable-signed access token carrying the external identity. |
| 39 | + |
| 40 | +--- |
| 41 | + |
| 42 | +## β
Prerequisites |
| 43 | + |
| 44 | +From the [Faable Dashboard](https://dashboard.faable.com): |
| 45 | + |
| 46 | +1. **A Client** β its `client_id` identifies the exchanging application. |
| 47 | +2. **An OIDC Connection acting as the trust anchor**, configured with: |
| 48 | + |
| 49 | +| Connection field | Purpose | |
| 50 | +| :--------------- | :------------------------------------------------------------------------------------------------------------------ | |
| 51 | +| `issuer` | The external issuer to trust β must equal the subject token's `iss` (e.g. `https://token.actions.githubusercontent.com`). | |
| 52 | +| `jwks_url` | Where to fetch the issuer's public keys. If omitted, Faable tries `<issuer>/.well-known/jwks` β set it explicitly unless your issuer serves keys there (GitHub does). | |
| 53 | +| `client_id` | The expected `aud` of the subject token. The workload must request its OIDC token with this audience. | |
| 54 | +| `claims_mapping` | Optional β which external claims to copy into the issued Faable token (see below). | |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +## π οΈ The Exchange Request |
| 59 | + |
| 60 | +- **Endpoint:** <TennantDomain url="/oauth/token"/> |
| 61 | +- **Method:** `POST` |
| 62 | +- **Content-Type:** `application/x-www-form-urlencoded` or `application/json` |
| 63 | + |
| 64 | +| Parameter | Required | Description | |
| 65 | +| :------------------- | :------- | :---------------------------------------------------------------------------------------------- | |
| 66 | +| `grant_type` | Yes | Must be `urn:ietf:params:oauth:grant-type:token-exchange`. | |
| 67 | +| `subject_token` | Yes | The external JWT to exchange (e.g. the GitHub Actions OIDC token). | |
| 68 | +| `subject_token_type` | Yes | Must be `urn:ietf:params:oauth:token-type:jwt` β the only type accepted. | |
| 69 | +| `client_id` | Yes | Your application's Client ID. | |
| 70 | + |
| 71 | +Extra body fields are allowed and can be lifted into the issued token via `claims_mapping` (as `request.<field>` β see below). |
| 72 | + |
| 73 | +### Response |
| 74 | + |
| 75 | +```json |
| 76 | +{ |
| 77 | + "token_type": "Bearer", |
| 78 | + "expires_in": 1800, |
| 79 | + "access_token": "eyJhbGciOiJSUzI1NiIs..." |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +Exchanged tokens are deliberately short-lived β **30 minutes** β and the flow returns **no refresh token and no ID token**: when it expires, the workload simply exchanges a fresh subject token. Decoded, the access token looks like: |
| 84 | + |
| 85 | +```json |
| 86 | +{ |
| 87 | + "sub": "repo:acme/webapp:ref:refs/heads/main", |
| 88 | + "repository": "acme/webapp", |
| 89 | + "actor": "boyander", |
| 90 | + "client_id": "YOUR_CLIENT_ID", |
| 91 | + "iss": "https://your-domain.auth.faable.link", |
| 92 | + "exp": 1751643000 |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +The `sub` is the external token's `sub` **verbatim** β for GitHub Actions, the repository/ref identity. `repository` and `actor` come from `claims_mapping`. |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +## π§© Mapping External Claims |
| 101 | + |
| 102 | +`claims_mapping` on the Connection controls which claims land in the issued token. Each entry maps an output claim to either a path or a literal: |
| 103 | + |
| 104 | +```json |
| 105 | +{ |
| 106 | + "repository": "token.repository", |
| 107 | + "actor": "token.actor", |
| 108 | + "environment": "request.environment", |
| 109 | + "via": "\"token-exchange\"" |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +- `token.<claim>` β copied from the **verified** subject token. |
| 114 | +- `request.<field>` β copied from the exchange request's body. |
| 115 | +- `"quoted"` values β static literals. |
| 116 | + |
| 117 | +Reserved claims (`iss`, `aud`, `sub`, `exp`, `iat`, `client_id`, `client`, `account`) are stripped from mappings β they're always controlled by Faable. Your backend then authorizes on these claims (e.g. only `repository: "acme/webapp"` may deploy). |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +## π Complete Example: GitHub Actions |
| 122 | + |
| 123 | +A workflow that authenticates to your API with **zero stored secrets**: |
| 124 | + |
| 125 | +```yaml |
| 126 | +jobs: |
| 127 | + deploy: |
| 128 | + runs-on: ubuntu-latest |
| 129 | + permissions: |
| 130 | + id-token: write # allow the job to request its OIDC token |
| 131 | + steps: |
| 132 | + - name: Exchange GitHub OIDC token for a Faable token |
| 133 | + run: | |
| 134 | + # 1. Get the GitHub OIDC token (audience = your Connection's client_id) |
| 135 | + GITHUB_JWT=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ |
| 136 | + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=YOUR_CLIENT_ID" | jq -r .value) |
| 137 | +
|
| 138 | + # 2. Exchange it at your Faable tenant |
| 139 | + FAABLE_TOKEN=$(curl -s -X POST 'https://your-domain.auth.faable.link/oauth/token' \ |
| 140 | + -H 'content-type: application/x-www-form-urlencoded' \ |
| 141 | + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ |
| 142 | + --data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:jwt' \ |
| 143 | + --data-urlencode "subject_token=$GITHUB_JWT" \ |
| 144 | + --data-urlencode 'client_id=YOUR_CLIENT_ID' | jq -r .access_token) |
| 145 | +
|
| 146 | + # 3. Call your API as the workflow's identity |
| 147 | + curl -H "authorization: Bearer $FAABLE_TOKEN" https://api.myapp.com/deploy |
| 148 | +``` |
| 149 | +
|
| 150 | +--- |
| 151 | +
|
| 152 | +## β οΈ Common Errors |
| 153 | +
|
| 154 | +Errors use the standard OAuth 2.0 format: `{ "error": "..." }`. |
| 155 | + |
| 156 | +| Status | Error | Fix | |
| 157 | +| :----- | :------------------------------------------------------------ | :-------------------------------------------------------------------------------- | |
| 158 | +| `400` | `Missing subject_token` / invalid JWT format / missing `iss` or `sub` claim | Send a complete, well-formed JWT as `subject_token`. | |
| 159 | +| `400` | `subject_token_type must be 'urn:ietf:params:oauth:token-type:jwt'` | Only JWT subject tokens are supported. | |
| 160 | +| `403` | `The OIDC issuer '<iss>' is not trusted by this account.` | Create a Connection whose `issuer` exactly matches the subject token's `iss`. | |
| 161 | +| `401` | `Invalid subject_token signature or audience` | Check the Connection's `jwks_url`, and request the subject token with `audience` = the Connection's `client_id`. | |
| 162 | +| `404` | `Client not found` | The `client_id` doesn't exist in this tenant. | |
| 163 | + |
| 164 | +--- |
| 165 | + |
| 166 | +## β FAQ |
| 167 | + |
| 168 | +### Does Token Exchange create a Faable user? |
| 169 | + |
| 170 | +No. Unlike social login, no user is matched or created β the issued token's `sub` is the external workload identity verbatim. It's machine identity federation, not user login. |
| 171 | + |
| 172 | +### How long does the exchanged token live? |
| 173 | + |
| 174 | +30 minutes, by design. There's no refresh token: exchange a fresh subject token when it expires (a CI job can mint a new OIDC token at any time). |
| 175 | + |
| 176 | +### Which external issuers can I trust? |
| 177 | + |
| 178 | +Any OIDC-compliant issuer that signs JWTs and publishes a JWKS β GitHub Actions, GitLab CI, Kubernetes service accounts, or another cloud's workload identity. One Connection per issuer. |
| 179 | + |
| 180 | +### How does my backend authorize these tokens? |
| 181 | + |
| 182 | +Verify them like any Faable token ([Validate Access Tokens](../validate-access-tokens.md)) and authorize on the mapped claims β for example, allow deploys only when `repository` equals your repo and `sub` points at your main branch. |
| 183 | + |
| 184 | +### When should I use Client Credentials instead? |
| 185 | + |
| 186 | +When your workload can hold a static secret securely (a server with a secret manager). Token Exchange shines where storing secrets is the problem β ephemeral CI runners, third-party compute. |
| 187 | + |
| 188 | +--- |
| 189 | + |
| 190 | +## π Related |
| 191 | + |
| 192 | +- **[Connections](../connections.md)** β the trust anchor configuration. |
| 193 | +- **[Validate Access Tokens](../validate-access-tokens.md)** β verify the exchanged tokens in your API. |
| 194 | +- **[Client Credentials Flow](client-credentials.md)** β the secret-based alternative for backend services. |
| 195 | +- **[GitHub β About security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)** β how GitHub Actions OIDC works. |
| 196 | +- **[RFC 8693 β OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)** β the official standard. |
0 commit comments