---
title: "RFC 8693 token exchange"
method: POST
path: "/ci/v1/token-exchange"
tags: ["CI Federation"]
---

# RFC 8693 token exchange

`POST /ci/v1/token-exchange`

## Request

- Base URL: `https://api.run402.com`
- URL: `https://api.run402.com/ci/v1/token-exchange`
- Auth: none required

Exchange a GitHub Actions OIDC JWT for a 15-min run402 session JWT. **NO Authorization header — the OIDC JWT in `subject_token` is the sole authentication.** JSON only (NOT form-encoded). Body cap 12 KiB; `subject_token` cap 8192 bytes (rejected before parse). Verification order: decode iss from PAYLOAD (NOT header), look up issuer in trusted_oidc_issuers (unknown → 401 without external JWKS fetch), `jose.jwtVerify` with `algorithms:["RS256"]` pinned and `clockTolerance:30s`, exact-string `aud` post-check, required GitHub claims as non-empty strings, candidate fetch via partial index, subject-pattern matching with deterministic precedence (exact > longer-prefix), `event_name ∈ allowed_events`, `repository_id` strong-bind if non-NULL, atomic conditional UPDATE (closes revoke-during-exchange race). The session JWT's `exp = min(iat+900, binding.expires_at_unix)` — `expires_in` may be < 900 if the binding expires sooner. Authority disclosed by the wallet-signed delegation: deployed function code runs with the project's runtime authority + database authority (migrations, RLS/expose changes); revocation does NOT undo deployed code.

## Request body

- object
  - `grant_type` 'urn:ietf:params:oauth:grant-type:token-exchange', required
  - `subject_token` string, required: GitHub Actions OIDC JWT, signed RS256, with required claims sub/run_id/sha/actor/workflow_ref/repository/repository_id/event_name.
  - `subject_token_type` 'urn:ietf:params:oauth:token-type:jwt', required
  - `project_id` string, required

## Response `200`

Session minted.

- object
  - `access_token` string: HS256 run402 session JWT signed with CI_SESSION_SECRET. Carries `token_use:"ci_session"`, `aud=sub=project_id`, `acts_as` (binding wallet, lowercase), `via` (binding id), `scopes` (copy of binding.allowed_actions), and audit fields (run_id/sha/actor/workflow_ref).
  - `token_type` 'Bearer'
  - `expires_in` integer: Effective seconds remaining. May be < 900 if the binding's expires_at is closer than 15 minutes.
  - `scope` string: Space-delimited list of binding.allowed_actions. v1: "deploy".

## Other responses

- `400`: Malformed request: bad grant_type, bad subject_token_type, missing project_id, oversized body or token.
- `401`: `invalid_token`: malformed/bad-signature/expired OIDC JWT, unknown issuer, wrong audience, missing required claim, non-RS256 algorithm.
- `403`: Valid JWT but no usable binding. Error codes: `access_denied` (no matching binding, OR revoked/expired between SELECT and atomic UPDATE), `event_not_allowed` (event_name not in binding.allowed_events), `repository_id_mismatch` (binding has non-NULL github_repository_id and JWT's repository_id differs), `ambiguous_binding` (multiple bindings match with equal specificity).

---

[API](https://skmtc.dev/run402/apis/run402-api.md) · [All operations](https://skmtc.dev/run402/apis/run402-api/llms.txt) · [OpenAPI document](https://skmtc.dev/run402/apis/run402-api/revisions/893607557f50?raw)
