OAuth Setup
End-to-end guide for integrating OAuth client_credentials with private_key_jwt for embedded components.
Client-side JWT signing is shown here for illustration only. In production, the token exchange (private key signing) MUST happen on your server — never in the browser.
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://<cgk2_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://<cgk2_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
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 |
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 |