VA
Acceptance

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:

  1. Step Aclient_credentials grant with private_key_jwt (RFC 7523): Your backend signs a JWT assertion and exchanges it for an access_token at the Visa token endpoint.
  2. Step B — RFC 8693 token exchange: Your backend exchanges the access_token for an ec_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.

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.

{
  "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>"
  }
}
ClaimValue
subYour client_id from registration
issYour associated_id (partner org)
audFull path to the token endpoint
kidThe key_id from KMS registration
v-c-merchant-id"internal" for service-to-service
expCurrent time + 300 seconds
jtiUnique per request (prevents replay)
act.org_idYour 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

ParameterValue
grant_typeurn:ietf:params:oauth:grant-type:token-exchange (RFC 8693)
subject_tokenThe access_token from Step A
subject_token_typeurn:ietf:params:oauth:token-type:access_token
requested_token_typeurn:visa:params:oauth:token-type:ec-token
component_typeboarding, 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:

  1. Initial acquisition — calls getToken() after IFRAME_READY, before component renders
  2. Proactive refresh — if the token is a JWT with exp, schedules refresh expiryBufferSeconds before expiry
  3. Reactive refresh — on AUTH_EXPIRED (401/403 from API), calls refreshToken() or getToken()
  4. Delivery — tokens are passed to the iframe via postMessage (never in URLs)
  5. Injection — inside the iframe, AuthProvider adds Authorization: Bearer <token> to all API requests

Troubleshooting

ErrorCauseFix
Token exchange failed (HTTP 400)Malformed JWT assertionCheck all required claims are present (iss, sub, aud, exp, iat, jti, scope, v-c-merchant-id)
unsupported_grant_typeWrong grant_type valueMust be exactly client_credentials
invalid_grantJWT decoding failed or claims missingVerify JWT has 3 dot-separated base64url segments
invalid_client (401)Client assertion expiredEnsure exp is in the future
EC token exchange failed (400)Wrong grant_type for Step BMust be urn:ietf:params:oauth:grant-type:token-exchange
invalid_token on /sms/v1/tokensaccess_token expired or malformedEnsure Step A succeeded and token hasn't expired (5 min lifetime)
invalid_request on /sms/v1/tokensMissing or invalid component_typeMust be one of: boarding, transaction_search, user_management

On this page