Exchange token
Exchange a partner-signed JWT for a CI HUB access token and refresh token.
Exchanges a partner-signed JWT for CI HUB access and refresh tokens. Required as the first call of every Access SDK session.
Partner JWT
Send the partner-signed JWT as Authorization: Bearer <partner-signed-jwt>.
Header
| Field | Required | Value |
|---|---|---|
alg | yes | RS256 |
kid | yes | Must match a key published in the partner JWKS |
typ | optional | JWT |
HS256 is not accepted.
Payload
| Claim | Type | Required | Notes |
|---|---|---|---|
iss | string | yes | Partner issuer URL. Must match the registered value exactly, including trailing slash. |
aud | string | yes | Registered audience. Default https://api.ci-hub.com. |
sub | string | yes | Stable identifier for the user in the partner system. |
iat | number | yes | Unix seconds. Tolerance: up to 30 seconds in the future. The token is rejected once it is older than maxTokenAge (now minus iat). |
exp | number | yes | Unix seconds. Must be in the future. |
email | string | yes (in JWT or body) | Used for just-in-time (JIT) user resolution: CI HUB finds the matching user or creates one on first exchange. Must parse as an email. JWT value takes precedence over body. |
given_name | string | optional | First name. Falls back to splitting name. |
family_name | string | optional | Last name. Falls back to splitting name. |
name | string | optional | Display name. Used when given_name and family_name are absent. |
The token must be no older than maxTokenAge seconds (default 3600), measured from
iat to the current time. Sign a fresh JWT for each exchange. Stale tokens are
rejected with cihub-sdk-token-invalid.
Send Content-Type: application/json so the JSON body parser picks up the request.
Only an application/json body is parsed for the email field; other content types
leave it unread. Name fields are not read from the body. They come from the partner
JWT claims (given_name, family_name, name).
Authorization
SdkPartnerAuth Partner-signed RS256 JWT verified against the partner's registered JWKS. Needs to be sent in the Authorization header. Only used by the token exchange.
In: header
Request Body
application/json
Optional. Used only as a fallback for partners that cannot include the email
claim in the partner JWT. If the JWT contains email, the body value is ignored.
The request must include email somewhere (JWT or body). If your JWT already
carries email, send {}.
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/auth/exchangeToken" \ -H "Content-Type: application/json" \ -d '{}'{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600,
"token_type": "Bearer"
}{
"message": "Error",
"details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
"errorCode": "cihub-sdk-token-invalid",
"error": {
"code": "integration-forbidden",
"source": "cihub",
"status": 400,
"message": "Access denied by the integration",
"details": "403 Forbidden - insufficient_permissions",
"provider": "bynder"
}
}{
"message": "Error",
"details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
"errorCode": "cihub-sdk-token-invalid",
"error": {
"code": "integration-forbidden",
"source": "cihub",
"status": 400,
"message": "Access denied by the integration",
"details": "403 Forbidden - insufficient_permissions",
"provider": "bynder"
}
}{
"message": "Error",
"details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
"errorCode": "cihub-sdk-token-invalid",
"error": {
"code": "integration-forbidden",
"source": "cihub",
"status": 400,
"message": "Access denied by the integration",
"details": "403 Forbidden - insufficient_permissions",
"provider": "bynder"
}
}{
"message": "Error",
"details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
"errorCode": "cihub-sdk-token-invalid",
"error": {
"code": "integration-forbidden",
"source": "cihub",
"status": 400,
"message": "Access denied by the integration",
"details": "403 Forbidden - insufficient_permissions",
"provider": "bynder"
}
}{
"message": "Error",
"details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
"errorCode": "cihub-sdk-token-invalid",
"error": {
"code": "integration-forbidden",
"source": "cihub",
"status": 400,
"message": "Access denied by the integration",
"details": "403 Forbidden - insufficient_permissions",
"provider": "bynder"
}
}Rate limit
The exchange endpoint is rate-limited per partner, keyed by your iss. The default is 60 requests per minute; CI HUB can raise it for a partner on request. Other Access SDK endpoints are not rate-limited at this layer; they are bounded by the access token's one-hour lifetime instead.
Over the limit, the call returns 429 cihub-rate-limited. Every response carries the standard RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers, and a 429 adds Retry-After. Exchange once per session and cache the returned tokens rather than exchanging per request; on a 429, wait for Retry-After before retrying.
Email outside the JWT
CI HUB needs an email to resolve the user, but it does not have to travel in the JWT. Partners whose tokens carry no email claim, including those who anonymize the address, send it in the JSON request body instead:
const session = await client.exchangeToken({
partnerJwt,
emailFallback: 'u-8f21c4@anon.your-platform.example.com',
})curl -X POST "https://stage.ci-hub.com/api/v1/auth/exchangeToken" \
-H "Authorization: Bearer $PARTNER_JWT" \
-H "Content-Type: application/json" \
-d '{"email": "u-8f21c4@anon.your-platform.example.com"}'const response = await fetch(
'https://stage.ci-hub.com/api/v1/auth/exchangeToken',
{
method: 'POST',
headers: {
Authorization: `Bearer ${partnerJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'u-8f21c4@anon.your-platform.example.com' }),
}
)A JWT email claim wins over the body, so send one or the other. Content-Type: application/json is required: no other content type is parsed for the field.
Two constraints apply to a pseudonymous address:
- It must parse as an email. A bare opaque id is rejected with
cihub-sdk-email-missing. A local part plus a domain you control is enough; the address does not have to receive mail. - It must be stable for the same person. The email is how CI HUB resolves a returning user. A value that changes between sessions creates a new CI HUB user each time, and each one takes a seat.
Names come from the JWT claims (given_name, family_name, name) only, never from the body.
Examples
The client library mints the partner JWT with its own server-only signer; the wire examples use the Node jsonwebtoken library. Any RS256-capable JWT library works; the contract is the JWT itself, not the language.
import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk'
import { signPartnerJwt } from '@ci-hub/access-sdk/node'
// Server-side: the private key must never reach a browser.
const partnerJwt = await signPartnerJwt({
privateKeyPem: process.env.PARTNER_PRIVATE_KEY!,
keyId: 'your-kid',
claims: {
iss: 'https://auth.your-platform.example.com',
aud: 'https://api.ci-hub.com',
sub: 'user-12345',
email: 'jane@customer.example.com',
},
})
const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' })
const tokens = new TokenManager(client)
const session = await client.exchangeToken({ partnerJwt })
await tokens.setCihubSession(session)PARTNER_JWT=$(node -e "
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('./private.pem');
const now = Math.floor(Date.now() / 1000);
console.log(jwt.sign({
iss: 'https://auth.your-platform.example.com',
aud: 'https://api.ci-hub.com',
sub: 'user-12345',
email: 'jane@customer.example.com',
iat: now,
exp: now + 3600
}, privateKey, { algorithm: 'RS256', keyid: 'your-kid' }));
")
curl -X POST "https://stage.ci-hub.com/api/v1/auth/exchangeToken" \
-H "Authorization: Bearer $PARTNER_JWT" \
-H "Content-Type: application/json" \
-d '{}'import fs from 'node:fs'
import jwt from 'jsonwebtoken'
const privateKey = fs.readFileSync('./private.pem')
const now = Math.floor(Date.now() / 1000)
const partnerJwt = jwt.sign(
{
iss: 'https://auth.your-platform.example.com',
aud: 'https://api.ci-hub.com',
sub: 'user-12345',
email: 'jane@customer.example.com',
iat: now,
exp: now + 3600,
},
privateKey,
{ algorithm: 'RS256', keyid: 'your-kid' }
)
const response = await fetch(
'https://stage.ci-hub.com/api/v1/auth/exchangeToken',
{
method: 'POST',
headers: {
Authorization: `Bearer ${partnerJwt}`,
'Content-Type': 'application/json',
},
body: '{}',
}
)
if (!response.ok) {
const { error } = await response.json()
throw new Error(`${error.code}: ${error.message}`)
}
const { access_token, refresh_token, expires_in } = await response.json()