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.
RS256 vs HS256 — why we upgraded
Section titled “RS256 vs HS256 — why we upgraded”| Property | HS256 (previous) | RS256 (current) |
|---|---|---|
| Algorithm | HMAC-SHA256 (symmetric) | RSA + SHA-256 (asymmetric) |
| Signing key | Shared secret — must be known to both issuer and verifier | Private key held only by Arbitex; verifiers use public key |
| Key distribution | Secret must be transmitted to every verifier | Public key distributed via JWKS; no secret sharing |
| Key rotation | Requires coordinated secret update on all verifiers | kid rotation is zero-downtime; old keys coexist with new |
| Outpost compatibility | HS256 tokens rejected by outpost (RSA-only validator) | RS256 accepted natively |
| JWKS-based validation | Not possible | Supported 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.
JWKS endpoint
Section titled “JWKS endpoint”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)
Response format
Section titled “Response format”{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "arb-2026-03-v1", "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt...", "e": "AQAB" } ]}| Field | Description |
|---|---|
kty | Key type — always RSA |
use | Key use — always sig (signature verification) |
alg | Algorithm — RS256 |
kid | Key ID — matches the kid header in issued JWTs |
n | RSA modulus (Base64url-encoded) |
e | RSA public exponent (Base64url-encoded, typically AQAB = 65537) |
Caching JWKS
Section titled “Caching JWKS”Clients should cache the JWKS response rather than fetching on every token validation. Recommended practice:
- Cache the JWKS response for a configurable TTL (recommended: 5 minutes)
- On token validation failure with an unknown
kid, re-fetch the JWKS immediately — this handles key rotation - 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):
- New key generated — new RSA key pair added to signing infrastructure
- New
kidpublished to JWKS — JWKS endpoint now returns both old and new keys - New tokens issued with new
kid— all new token issuance switches to the new key - Old key retained — old key remains in JWKS until all tokens signed with it have expired
- 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 type | Validity |
|---|---|
| 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.
Fetching and validating RS256 JWTs
Section titled “Fetching and validating RS256 JWTs”Python — validate RS256 JWT
Section titled “Python — validate RS256 JWT”import jwtimport requestsfrom 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 claimsNode.js — validate RS256 JWT
Section titled “Node.js — validate RS256 JWT”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); } ); });}Configuring JWKS URL in the Outpost
Section titled “Configuring JWKS URL in the Outpost”To enable JWT validation in the Arbitex Outpost using the JWKS endpoint:
# Configure JWKS URL for automatic key discoveryexport 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):
# Fetch the public key from JWKS and convert to PEM (run once)python3 -c "import requests, jsonfrom jwt.algorithms import RSAAlgorithmjwks = requests.get('https://api.arbitex.ai/.well-known/jwks.json').json()# Use the first (current) signing keykey = 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 keyexport 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.
JWT payload claims
Section titled “JWT payload claims”Arbitex RS256 JWTs include the following standard and custom claims:
| Claim | Type | Description |
|---|---|---|
sub | string | OAuth client ID |
iss | string | https://api.arbitex.ai |
aud | string | https://api.arbitex.ai |
iat | number | Issued-at time (Unix timestamp) |
exp | number | Expiry time (Unix timestamp) |
jti | string | Unique JWT ID (for replay detection) |
scope | string | Space-separated scopes (e.g., api:read api:write) |
org_id | string | Organization ID (UUID) |
token_type | string | Always "m2m" for M2M tokens |
rate_limit_tier | string | standard, premium, or unlimited |
Redis session store
Section titled “Redis session store”Arbitex uses Redis as a distributed session store for user sessions (portal authentication). This design eliminates sticky session requirements.
Design properties
Section titled “Design properties”| Property | Value |
|---|---|
| Session store | Azure Cache for Redis |
| Session key format | session:<session_id_uuid> |
| Session TTL | Configurable per org policy |
| Stickiness required | No — any pod can handle any request |
| Failover behavior | Session lookup fails if Redis is unavailable; user must re-authenticate |
Scaling implications
Section titled “Scaling implications”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
OAuth token cache
Section titled “OAuth token cache”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 JSONOAuth client secret rotation
Section titled “OAuth client secret rotation”OAuth client secrets can be rotated without service interruption. The rotation procedure:
-
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-secretAuthorization: Bearer arb_live_admin-keyResponse includes the new
client_secretin plaintext — this is the only time it is visible. -
Update client configuration — update the secret in all systems using this client.
-
Verify new tokens — issue a test token using the new secret and validate it.
-
Old tokens expire — tokens issued with the old secret remain valid until their
expclaim. 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.
See also
Section titled “See also”- OAuth 2.0 M2M API — Obtaining M2M tokens using the Client Credentials flow
- Outpost OAuth JWT Validation — Configure the Outpost to validate RS256 JWTs
- OAuth client administration — Manage OAuth clients, secrets, rate limit tiers
- Security Trust Center — RS256 in the authentication architecture overview