Access Token

View as MarkdownOpen in Claude

This guide documents the implementation of the Access Token step in an OAuth 2.0 flow customized for the Interactive Brokers (IBKR) Web API. Unlike standard OAuth 2.0 client credentials grants that transmit a static client secret, this implementation uses a signed JWT client assertion (per RFC 7523, the JWT Bearer grant extension), constructed as a compact JWS and signed with RSA-SHA256.

This is the first of two OAuth 2.0 steps in the IBKR flow:

  1. Access Token (documented here)
  2. Bearer Token / SSO Session establishment (exchanges the Access Token for a gateway session — a separate flow)
1

Prerequisites

Before implementing this flow, ensure you have:

RequirementDescription
Client ID (clientId)Issued by IBKR upon API application registration; used as both iss and sub in the JWT claims
Client Key ID (clientKeyId)Identifies which registered public key should be used to verify the JWT signature; sent in the JWT header as kid
RSA Private KeyUsed to sign the JWT (jwtPrivateKey); loaded from a PEM file and imported via Crypto.PublicKey.RSA.import_key()
ScopeSpace-delimited scope string requested for the access token (e.g. sso-sessions.write)
OAuth2 Base URLIBKR’s OAuth2 token endpoint host (oauth2Url)
AudienceFixed literal path value /tokennot the fully-qualified URL
Dependenciespycryptodome (for RSA, SHA256, PKCS1_v1_5), requests, standard base64, json, time, math
2

Construct the Endpoint URL

The Access Token endpoint is always accessed via POST over HTTPS, using application/x-www-form-urlencoded content.

1url = f'{oauth2Url}/api/v1/token'
2headers = {
3 "Content-Type": "application/x-www-form-urlencoded"
4}
3

Assemble the JWT Header and Claims

1now = math.floor(time.time())
2header = {
3 'alg': 'RS256',
4 'typ': 'JWT',
5 'kid': f'{clientKeyId}'
6}
7claims = {
8 'iss': f'{clientId}',
9 'sub': f'{clientId}',
10 'aud': f'{audience}',
11 'exp': now + 20,
12 'iat': now - 10
13}
FieldPurpose
algMust be RS256 — the signing algorithm identifier expected by IBKR’s JWT verifier
typStandard JWT type header, always "JWT"
kidIdentifies the registered public key (by clientKeyId) IBKR should use to verify the signature
iss / subBoth set to your clientId — IBKR requires the assertion to self-identify as both issuer and subject for this grant type
audThe literal string "/token"note this is a path segment, not the full endpoint URL; deviating from this value will cause signature/claim validation failures
expAssertion expiry — deliberately short-lived (20 seconds from generation) since this JWT authenticates a single token request, not a session
iatIssued-at, backdated by 10 seconds to tolerate minor clock skew between client and IBKR’s servers

Implementation note: compute_client_assertion() is a shared helper reused later for the Bearer Token step, where it builds a different claim set (ip, credential, exp of 24 hours) based on the target url. Only the branch matching {oauth2Url}/api/v1/token is relevant to this step.

4

Base64URL-Encode the Header and Claims

1def base64_encode(val):
2 return base64.b64encode(val).decode().replace('+', '-').replace('/', '_').rstrip('=')
3
4json_header = json.dumps(header, separators=(',', ':')).encode()
5encoded_header = base64_encode(json_header)
6json_claims = json.dumps(claims, separators=(',', ':')).encode()
7encoded_claims = base64_encode(json_claims)
8payload = f"{encoded_header}.{encoded_claims}"

Implementation details:

  1. Both header and claims are serialized to compact JSON (no whitespace) before encoding, to ensure a deterministic byte representation.
  2. Encoding uses base64url (per RFC 4648 §5), not standard base64 — + and / are substituted with - and _, and trailing = padding is stripped. This is required by the JWS compact serialization spec, and standard base64.b64encode output must be manually converted, as shown.
  3. The header and claims segments are joined with a literal . to form the unsigned payload.
5

Sign the Payload with RSA-SHA256

1md = SHA256.new(payload.encode())
2signer = PKCS1_v1_5.new(jwtPrivateKey)
3signature = signer.sign(md)
4encoded_signature = base64_encode(signature)

Process:

  1. Encode the payload (encoded_header.encoded_claims) to UTF-8 bytes.
  2. Compute the SHA-256 digest.
  3. Sign the digest using PKCS#1 v1.5 padding with your RSA private key (jwtPrivateKey — a Crypto.PublicKey.RSA key object).
  4. Base64URL-encode the raw signature bytes using the same encoding helper as the header/claims — not standard base64.
6

Assemble the Client Assertion (JWS)

1assertion = payload + "." + encoded_signature

Concatenating encoded_header, encoded_claims, and encoded_signature with . separators produces the compact JWS serialization:

base64url(header) . base64url(claims) . base64url(signature)

This full string is the client_assertion value submitted in the token request. It is self-contained and stateless — no separate signing request or nonce exchange is required, unlike the OAuth 1.0a Request Token flow.

7

Construct the Token Request Body

1form_data = {
2 'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
3 'client_assertion': assertion,
4 'grant_type': 'client_credentials',
5 'scope': scope
6}
FieldPurpose
client_assertion_typeFixed URN identifying the assertion as a JWT-bearer credential, per RFC 7523
client_assertionThe signed JWS produced in the previous step
grant_typeFixed value client_credentials — IBKR issues the access token directly against the assertion, with no authorization code or user redirect involved
scopeRequested scope string (e.g. sso-sessions.write), constraining what the resulting access token may be used for

Unlike the OAuth 1.0a Request Token step, no Authorization header is used here — all credentials are conveyed via the signed assertion in the form body.

8

Execute the Request and retrieve the access_token

1token_request = requests.post(url=url, headers=headers, data=form_data)
2print(web_header_print(token_request))
3
4if token_request.status_code == 200:
5 access_token = token_request.json()["access_token"]

The request is sent as a standard form-encoded POST; the signed assertion inside form_data carries all authentication material. The returned access_token should be retained for the subsequent Bearer Token / SSO Session step — it is passed as a Bearer credential in that request’s Authorization header and is not a long-lived credential; treat it as scoped to the immediate session-establishment exchange.