OAuth Setup
End-to-end guide for integrating OAuth client_credentials with private_key_jwt for embedded components.
Client-side JWT signing shown here is for local development only. In production, the token exchange (private key signing) MUST happen on your server.
Overview
The OAuth flow for embedded components is a two-step process:
- Step A —
client_credentialsgrant withprivate_key_jwt(RFC 7523): Your backend signs a JWT assertion and exchanges it for anaccess_tokenat the Visa token endpoint. - Step B — RFC 8693 token exchange: Your backend exchanges the
access_tokenfor anec_token(embedded component token) scoped to a specific component type.
The ec_token is what your getToken() callback returns to the SDK.
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌───────────────┐
│ Your Backend │────▶│ CGK2 Gateway │────▶│ PDS2 (validates │────▶│ apiauthservice│
│ (signs JWT) │ │ (mTLS) │ │ JWT signature) │ │ (issues token)│
└──────┬───────┘ └──────────────┘ └─────────────────┘ └───────────────┘
│ │
│ access_token (Step A) │
▼ ▼
┌──────────────┐ { access_token }
│ Token │
│ Exchange │──── POST /sms/v1/tokens (Step B) ────────────────▶ { ec_token }
└──────┬───────┘
│ ec_token
▼
┌──────────────┐
│ SDK getToken │ ◀────────────────────────── returns ec_token
│ callback │
└──────┬───────┘
│ postMessage AUTH_CREDENTIALS
▼
┌──────────────┐
│ Iframe │ → Authorization: Bearer <ec_token> on every API call
└──────────────┘Prerequisites
Register an RSA keypair with KMS
Generate a 2048-bit RSA key and register the public key on celtic KMS:
# Generate PKCS#8 private key
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private-key.pem
# Create CSR (KMS requires CSR format)
openssl req -new -key private-key.pem -out /tmp/my-app.csr -sha256 -subj "/CN=my-app"
# Register with KMS → returns key_id
curl -X POST "https://secureus-kms.ingress.celtic.visa.com:8443/kms/v2/keys-asym" \
--cert <mtls_cert> --key <mtls_key> \
-H "Content-Type: application/json" \
-d '{ "keyInformation": [{ "organizationId": "<your_org>", "cert": "<csr_base64>" }] }'Save the returned keyId — it becomes the kid in your JWT header.
Register an OAuth client
POST /apiauthservice/v4/oauth/clients
Headers:
v-c-permissions: OauthCreatePermission
v-c-associated-id: <your_org>
Body:
{
"client_name": "My Embedded App",
"client_type": "confidential",
"application_type": "service",
"authorized_grant_types": ["client_credentials"],
"key_id": "<key_id_from_kms>",
"key_type": "certificate",
"key_login": "<your_org>",
"associated_id": "<your_org>",
"scope": ["<scope_id>"]
}Response returns client_id and client_secret.
Create a client-credential-grant (consent)
Links your client to an organization and scopes:
POST /apiauthservice/v4/oauth/client-credential-grants
Headers:
v-c-permissions: OauthApplicationGrantPermission
v-ic-eft-orgid: <portfolio_id>
Body:
{
"clientId": "<client_id>",
"orgId": "<portfolio_giving_consent>",
"scope": ["<scope_id>"],
"platformId": "v4.0",
"status": "ACTIVE"
}JWT Assertion Format
The client_assertion is a signed JWT (RS256) proving client identity.
Header
{
"kid": "<key_id_from_kms>",
"typ": "JWT",
"alg": "RS256"
}Payload
{
"sub": "<client_id>",
"aud": "https://<cgk2_host>/oauth2/v4/token",
"iss": "<your_org>",
"exp": 1717200600,
"iat": 1717200300,
"jti": "<unique_uuid>",
"scope": "<scope_name>",
"v-c-merchant-id": "internal",
"acr": "voice",
"act": {
"sub": "<your_org>",
"org_id": "<your_org>",
"sub_id": "<user_id>"
}
}| Claim | Value |
|---|---|
sub | Your client_id from registration |
iss | Your associated_id (partner org) |
aud | Full path to the token endpoint |
kid | The key_id from KMS registration |
v-c-merchant-id | "internal" for service-to-service |
exp | Current time + 300 seconds |
jti | Unique per request (prevents replay) |
act.org_id | Your real portfolio ID (the org granting consent) |
act.org_id must be a real portfolio ID — the same value used in the client-credential-grant consent. Do NOT use "internal" for this field. Using an incorrect org_id will return a valid access_token but the subsequent EC token exchange will fail with a scope mismatch.
Token Exchange Request
POST https://<cgk2_host>/oauth2/v4/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed_jwt>
&scope=<requested_scopes>Response
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "transaction_search"
}EC Token Exchange (Step B)
After obtaining the access_token from Step A, exchange it for a component-scoped ec_token using RFC 8693 token exchange:
POST https://<ec_host>/sms/v1/tokens
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer <access_token>
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<access_token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:visa:params:oauth:token-type:ec-token
&component_type=<component_type>Parameters
| Parameter | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693) |
subject_token | The access_token from Step A |
subject_token_type | urn:ietf:params:oauth:token-type:access_token |
requested_token_type | urn:visa:params:oauth:token-type:ec-token |
component_type | boarding, transaction_search, or user_management |
Response
{
"access_token": "eyJ...",
"issued_token_type": "urn:visa:params:oauth:token-type:ec-token",
"token_type": "Bearer",
"expires_in": 1800,
"component_type": "transaction_search"
}The ec_token has a longer lifetime (30 minutes) than the initial access_token (5 minutes). Your getToken() callback should return the ec_token, not the raw access_token.
SDK Integration
// Server: /api/embed-token endpoint (Step A + Step B)
import { SignJWT, importPKCS8 } from 'jose';
import { readFileSync } from 'fs';
const TOKEN_ENDPOINT = 'https://<cgk2_host>/oauth2/v4/token';
const EC_TOKEN_ENDPOINT = 'https://<ec_host>/sms/v1/tokens';
const CLIENT_ID = '<client_id>';
const ORG_ID = '<your_org>'; // Must be a real portfolio ID
const KEY_ID = '<key_id_from_kms>';
const privateKeyPem = readFileSync('./private-key.pem', 'utf8');
const privateKey = await importPKCS8(privateKeyPem, 'RS256');
app.post('/api/embed-token', async (req, res) => {
// Step A: Get access_token via client_credentials + private_key_jwt
const assertion = await new SignJWT({
scope: 'transaction_search',
'v-c-merchant-id': 'internal',
acr: 'voice',
act: { sub: ORG_ID, org_id: ORG_ID, sub_id: req.user.id },
})
.setProtectedHeader({ alg: 'RS256', kid: KEY_ID, typ: 'JWT' })
.setSubject(CLIENT_ID)
.setIssuer(ORG_ID)
.setAudience(TOKEN_ENDPOINT)
.setIssuedAt()
.setExpirationTime('5m')
.setJti(crypto.randomUUID())
.sign(privateKey);
const tokenResp = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: assertion,
scope: 'transaction_search',
}),
});
const { access_token } = await tokenResp.json();
// Step B: Exchange access_token for ec_token
const ecResp = await fetch(EC_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Bearer ${access_token}`,
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: access_token,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
requested_token_type: 'urn:visa:params:oauth:token-type:ec-token',
component_type: 'transaction_search',
}),
});
const { access_token: ec_token, expires_in } = await ecResp.json();
res.json({ access_token: ec_token, expires_in });
});@PostMapping("/api/embed-token")
public ResponseEntity<Map<String, Object>> getEmbedToken() {
String assertion = Jwts.builder()
.setHeaderParam("kid", keyId)
.setSubject(clientId)
.setIssuer(orgId)
.setAudience(tokenEndpoint)
.setIssuedAt(new Date())
.setExpiration(Date.from(Instant.now().plusSeconds(300)))
.setId(UUID.randomUUID().toString())
.claim("scope", "transaction_search")
.claim("v-c-merchant-id", "internal")
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
// Exchange assertion for access token
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_assertion_type",
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
body.add("client_assertion", assertion);
Map<String, Object> response = restTemplate.postForObject(
tokenEndpoint, new HttpEntity<>(body, headers), Map.class);
return ResponseEntity.ok(response);
}// Frontend: Initialize SDK with OAuth
VisaAcceptance.init({
auth: {
type: 'oauth',
getToken: async () => {
// Call YOUR backend — never sign JWTs client-side in production
const resp = await fetch('/api/embed-token', { method: 'POST' });
const { access_token } = await resp.json();
return access_token;
},
refreshToken: async () => {
const resp = await fetch('/api/embed-token', { method: 'POST' });
const { access_token } = await resp.json();
return access_token;
},
expiryBufferSeconds: 60, // Proactive refresh 60s before expiry
},
});
VisaAcceptance.render('transactions', document.getElementById('container'));Token Lifecycle
The SDK manages the full token lifecycle automatically:
- Initial acquisition — calls
getToken()afterIFRAME_READY, before component renders - Proactive refresh — if the token is a JWT with
exp, schedules refreshexpiryBufferSecondsbefore expiry - Reactive refresh — on
AUTH_EXPIRED(401/403 from API), callsrefreshToken()orgetToken() - Delivery — tokens are passed to the iframe via
postMessage(never in URLs) - Injection — inside the iframe,
AuthProvideraddsAuthorization: Bearer <token>to all API requests
Local Development
For local development without a real Visa backend, the embed system includes dev stubs for both steps:
- Start
vap-proxy(nx serve vap-proxy) — registers both:POST /oauth2/v4/token— Step A stub (client_credentials → access_token)POST /sms/v1/tokens— Step B stub (token exchange → ec_token)
- Start
embed-host(nx serve embed-host) — serves the demo page - Open
http://localhost:8080/demo-oauth.html - The demo page signs JWTs client-side using Web Crypto, exchanges for an access_token, then exchanges for an ec_token
The dev stubs validate JWT structure and claim presence but do NOT verify cryptographic signatures or mTLS. They are for integration testing only.
Dev stub endpoints
| Endpoint | Purpose | Lifetime |
|---|---|---|
http://localhost:3000/oauth2/v4/token | Issues mock access_token (HS256) | 300s |
http://localhost:3000/sms/v1/tokens | Exchanges access_token → mock ec_token | 1800s |
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
Token exchange failed (HTTP 400) | Malformed JWT assertion | Check all required claims are present (iss, sub, aud, exp, iat, jti, scope, v-c-merchant-id) |
unsupported_grant_type | Wrong grant_type value | Must be exactly client_credentials |
invalid_grant | JWT decoding failed or claims missing | Verify JWT has 3 dot-separated base64url segments |
invalid_client (401) | Client assertion expired | Ensure exp is in the future |
| Token exchange timed out | Network issue or endpoint down | Check vap-proxy is running on port 3000 |
| SDK not loaded | Embed-host not running | Start with nx serve embed-host |
Failed to fetch private key | Key file missing | Run key generation: npx tsx apps/embed-host/src/generate-dev-key.ts |
EC token exchange failed (400) | Wrong grant_type for Step B | Must be urn:ietf:params:oauth:grant-type:token-exchange |
invalid_token on /sms/v1/tokens | access_token expired or malformed | Ensure Step A succeeded and token hasn't expired (5 min lifetime) |
invalid_request on /sms/v1/tokens | Missing or invalid component_type | Must be one of: boarding, transaction_search, user_management |