Skip to content

RS256 JWT Tokens and JWKS

Platform release platform-0042 upgraded Arbitex M2M token issuance from HS256 to RS256. This guide covers the RS256 token format, the JWKS endpoint, key ID (kid) rotation, backward compatibility, and the Redis-backed distributed session store.


PropertyHS256 (previous)RS256 (current)
AlgorithmHMAC-SHA256 (symmetric)RSA + SHA-256 (asymmetric)
Signing keyShared secret — must be known to both issuer and verifierPrivate key held only by Arbitex; verifiers use public key
Key distributionSecret must be transmitted to every verifierPublic key distributed via JWKS; no secret sharing
Key rotationRequires coordinated secret update on all verifierskid rotation is zero-downtime; old keys coexist with new
Outpost compatibilityHS256 tokens rejected by outpost (RSA-only validator)RS256 accepted natively
JWKS-based validationNot possibleSupported via /.well-known/jwks.json

The HS256 design required sharing the signing secret with any service that needed to validate tokens — a security risk if the secret was extracted. RS256 eliminates secret sharing: the private key never leaves Arbitex signing infrastructure, and any service can verify tokens using the public JWKS.

The outpost JWT validator enforces RSA algorithms only (RS256, RS384, RS512) and rejects HS256 tokens at the algorithm check. The platform upgrade to RS256 removes the incompatibility documented in previous releases.


The JSON Web Key Set (JWKS) endpoint publishes the RSA public keys used to sign Arbitex JWTs.

URL: https://api.arbitex.ai/.well-known/jwks.json

Method: GET

Authentication: None required (public endpoint)

{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "arb-2026-03-v1",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt...",
"e": "AQAB"
}
]
}
FieldDescription
ktyKey type — always RSA
useKey use — always sig (signature verification)
algAlgorithm — RS256
kidKey ID — matches the kid header in issued JWTs
nRSA modulus (Base64url-encoded)
eRSA public exponent (Base64url-encoded, typically AQAB = 65537)

Clients should cache the JWKS response rather than fetching on every token validation. Recommended practice:

  1. Cache the JWKS response for a configurable TTL (recommended: 5 minutes)
  2. On token validation failure with an unknown kid, re-fetch the JWKS immediately — this handles key rotation
  3. Do not fetch JWKS on every validation request; this creates unnecessary load and latency

The JWKS endpoint returns all currently active public keys. During a rotation window, two keys (old and new) will be present in the response simultaneously.


kid rotation — zero-downtime key rotation

Section titled “kid rotation — zero-downtime key rotation”

Every issued JWT includes a kid (key ID) header identifying which signing key was used:

{
"alg": "RS256",
"typ": "JWT",
"kid": "arb-2026-03-v1"
}

Key rotation procedure (Arbitex-managed):

  1. New key generated — new RSA key pair added to signing infrastructure
  2. New kid published to JWKS — JWKS endpoint now returns both old and new keys
  3. New tokens issued with new kid — all new token issuance switches to the new key
  4. Old key retained — old key remains in JWKS until all tokens signed with it have expired
  5. Old key removed — after the maximum token lifetime (86,400 seconds), old key is removed from JWKS

During steps 1–4, tokens signed with either the old or new key validate successfully. There is no validation outage during rotation.

For services caching JWKS: if a token arrives with a kid not in the cache, re-fetch JWKS before failing validation — the key may have been rotated since the cache was populated.


Backward compatibility — HS256 migration window

Section titled “Backward compatibility — HS256 migration window”

During the migration window following the platform-0042 upgrade, HS256 tokens issued to existing OAuth clients continue to be accepted.

Token typeValidity
RS256 tokens (new)Validated against JWKS public key
HS256 tokens (existing clients)Validated against stored client secret (backward compatible)

The migration window duration depends on the configured token_lifetime_seconds for each OAuth client. Once existing HS256 tokens expire and clients re-issue using the RS256 flow, HS256 validation can be disabled.

New OAuth clients created after platform-0042 receive RS256 by default. Existing clients are migrated to RS256 upon their next token issuance cycle.


import jwt
import requests
from functools import lru_cache
JWKS_URL = "https://api.arbitex.ai/.well-known/jwks.json"
@lru_cache(maxsize=1)
def get_jwks():
"""Fetch and cache JWKS. Clear cache on unknown kid."""
response = requests.get(JWKS_URL, timeout=5)
response.raise_for_status()
return response.json()
def get_public_key(kid: str):
"""Get RSA public key for a given kid."""
from jwt.algorithms import RSAAlgorithm
jwks = get_jwks()
for key in jwks["keys"]:
if key["kid"] == kid:
return RSAAlgorithm.from_jwk(key)
# kid not found in cache — clear and retry once
get_jwks.cache_clear()
jwks = get_jwks()
for key in jwks["keys"]:
if key["kid"] == kid:
return RSAAlgorithm.from_jwk(key)
raise ValueError(f"Unknown kid: {kid}")
def validate_token(token: str, required_scope: str = "api:write") -> dict:
"""Validate an RS256 JWT and return claims."""
# Decode header to get kid without verification
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if not kid:
raise ValueError("Token missing kid header")
public_key = get_public_key(kid)
claims = jwt.decode(
token,
public_key,
algorithms=["RS256"],
options={"require": ["exp", "iat", "sub", "scope"]},
leeway=60, # 60-second clock skew tolerance
)
# Verify scope
scope = claims.get("scope", "")
if isinstance(scope, list):
scopes = scope
else:
scopes = scope.split()
if required_scope not in scopes:
raise PermissionError(f"Token missing required scope: {required_scope}")
return claims
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://api.arbitex.ai/.well-known/jwks.json',
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 5 * 60 * 1000, // 5 minutes
rateLimit: true,
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
const signingKey = key.getPublicKey();
callback(null, signingKey);
});
}
function validateToken(token, requiredScope = 'api:write') {
return new Promise((resolve, reject) => {
jwt.verify(
token,
getKey,
{
algorithms: ['RS256'],
clockTolerance: 60, // 60-second clock skew
},
(err, decoded) => {
if (err) return reject(err);
// Verify scope
const scopes = Array.isArray(decoded.scope)
? decoded.scope
: (decoded.scope || '').split(' ');
if (!scopes.includes(requiredScope)) {
return reject(new Error(`Missing required scope: ${requiredScope}`));
}
resolve(decoded);
}
);
});
}

To enable JWT validation in the Arbitex Outpost using the JWKS endpoint:

Terminal window
# Configure JWKS URL for automatic key discovery
export OAUTH_JWKS_URL="https://api.arbitex.ai/.well-known/jwks.json"

Alternatively, configure a static PEM public key (useful in air-gap deployments where the outpost cannot reach the JWKS endpoint):

Terminal window
# Fetch the public key from JWKS and convert to PEM (run once)
python3 -c "
import requests, json
from jwt.algorithms import RSAAlgorithm
jwks = requests.get('https://api.arbitex.ai/.well-known/jwks.json').json()
# Use the first (current) signing key
key = RSAAlgorithm.from_jwk(jwks['keys'][0])
print(key.public_bytes(
encoding=__import__('cryptography.hazmat.primitives.serialization', fromlist=['Encoding']).Encoding.PEM,
format=__import__('cryptography.hazmat.primitives.serialization', fromlist=['PublicFormat']).PublicFormat.SubjectPublicKeyInfo
).decode())
"
# Export the PEM key
export OAUTH_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
<paste key here>
-----END PUBLIC KEY-----"

For air-gap deployments, use the static PEM approach and update the key manually when the signing key rotates.

See Outpost OAuth JWT Validation for the full outpost JWT configuration reference.


Arbitex RS256 JWTs include the following standard and custom claims:

ClaimTypeDescription
substringOAuth client ID
issstringhttps://api.arbitex.ai
audstringhttps://api.arbitex.ai
iatnumberIssued-at time (Unix timestamp)
expnumberExpiry time (Unix timestamp)
jtistringUnique JWT ID (for replay detection)
scopestringSpace-separated scopes (e.g., api:read api:write)
org_idstringOrganization ID (UUID)
token_typestringAlways "m2m" for M2M tokens
rate_limit_tierstringstandard, premium, or unlimited

Arbitex uses Redis as a distributed session store for user sessions (portal authentication). This design eliminates sticky session requirements.

PropertyValue
Session storeAzure Cache for Redis
Session key formatsession:<session_id_uuid>
Session TTLConfigurable per org policy
Stickiness requiredNo — any pod can handle any request
Failover behaviorSession lookup fails if Redis is unavailable; user must re-authenticate

Because sessions are stored in Redis rather than in-memory on a specific pod:

  • Horizontal scaling (adding API pods) requires no session coordination
  • Rolling deployments do not invalidate active sessions
  • Pod termination does not lose sessions for affected users

M2M token validation results are cached in Redis with a TTL equal to the token’s remaining lifetime. This avoids repeated JWT signature verification for long-lived tokens in high-throughput scenarios.

Cache key: oauth_token:<sha256(token)>
TTL: min(token_exp - now, configured_cache_max)
Value: validated claims JSON

OAuth client secrets can be rotated without service interruption. The rotation procedure:

  1. Create new client or rotate secret via the admin API (or OAuth Client Manager UI):

    Terminal window
    POST https://api.arbitex.ai/api/admin/oauth-clients/{client_id}/rotate-secret
    Authorization: Bearer arb_live_admin-key

    Response includes the new client_secret in plaintext — this is the only time it is visible.

  2. Update client configuration — update the secret in all systems using this client.

  3. Verify new tokens — issue a test token using the new secret and validate it.

  4. Old tokens expire — tokens issued with the old secret remain valid until their exp claim. No revocation of in-flight tokens is required.

The old client secret is invalidated immediately on rotation. Tokens already issued with the old secret continue to be valid for their remaining lifetime because the RS256 signature is validated against the JWKS public key, not the client secret.

See OAuth client administration for the full client management reference.