JWKS & Discovery
ZB ID exposes standard OpenID Connect discovery endpoints that allow services to verify JWT tokens locally. By fetching the public key from the JWKS endpoint, your service can validate token signatures without making a network call to ZB ID on every request.
JWKS endpoint
The JSON Web Key Set (JWKS) endpoint returns the RSA public key that ZB ID uses to sign JWT tokens. Services use this key to verify token signatures locally.
The response follows the standard JWKS format defined in RFC 7517. Each key in the keys array includes the following fields:
- Name
kty- Type
- string
- Description
Key type. Always
RSAfor ZB ID tokens.
- Name
use- Type
- string
- Description
Key usage. Always
sig(signature verification).
- Name
kid- Type
- string
- Description
Key identifier. Use this to match the
kidheader in incoming JWTs to the correct public key.
- Name
alg- Type
- string
- Description
Algorithm. Always
RS256for ZB ID tokens.
- Name
n- Type
- string
- Description
The RSA modulus, Base64url-encoded.
- Name
e- Type
- string
- Description
The RSA public exponent, Base64url-encoded.
Caching
The JWKS response should be cached by your service. Refresh the cache when you encounter a JWT with a kid that does not match any key in your cached set, or on a regular interval (every 24 hours is a reasonable default). ZB ID rotates keys periodically, but the previous key remains valid during a transition window to avoid breaking existing tokens.
Request
curl https://id.zb.co.zw/.well-known/jwks.json
Response
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "zb-id-2026-01",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw...",
"e": "AQAB"
}
]
}
Fetch the live JWKS
Public endpoint, no token needed. Returns the current signing keys used to verify ZB ID access tokens on staging.
Runs against the ZB ID STAGING sandbox (id-staging.zb.co.zw). Register a throwaway test account; never use real credentials.
OpenID discovery
The OpenID Connect discovery endpoint returns metadata about the ZB ID authorization server. Clients and services use this document to discover endpoint URLs, supported grant types, and other configuration details automatically.
The URLs in the discovery document all sit on the production base URL (https://id.zb.co.zw), which is also the issuer identifier carried in every token's iss claim. The staging environment issues tokens with iss https://id-staging.zb.co.zw.
Response fields
- Name
issuer- Type
- string
- Description
The issuer identifier of the ZB ID authorization server (
https://id.zb.co.zw). Tokens issued by ZB ID carry this value in theirissclaim, so verify theissclaim against it.
- Name
jwks_uri- Type
- string
- Description
URL of the JWKS endpoint for retrieving public keys.
- Name
token_endpoint- Type
- string
- Description
URL for obtaining tokens via the OAuth token grant flow.
- Name
introspection_endpoint- Type
- string
- Description
URL for validating and inspecting tokens.
- Name
grant_types_supported- Type
- array
- Description
The OAuth grant types supported by ZB ID. Only
client_credentialsis advertised.
- Name
scopes_supported- Type
- array
- Description
List of scopes that can be requested during token issuance.
- Name
response_types_supported- Type
- array
- Description
Supported OAuth response types.
- Name
subject_types_supported- Type
- array
- Description
Subject identifier types supported.
- Name
id_token_signing_alg_values_supported- Type
- array
- Description
Signing algorithms supported for ID tokens and JWTs.
Request
curl https://id.zb.co.zw/.well-known/openid-configuration
Response
{
"issuer": "https://id.zb.co.zw",
"jwks_uri": "https://id.zb.co.zw/.well-known/jwks.json",
"token_endpoint": "https://id.zb.co.zw/oauth/token",
"introspection_endpoint": "https://id.zb.co.zw/oauth/introspect",
"grant_types_supported": [
"client_credentials"
],
"response_types_supported": [
"token"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"profile",
"banking:read",
"banking:write",
"lending:read",
"lending:write",
"insurance:read",
"insurance:write",
"wealth:read",
"wealth:write",
"admin:users",
"admin:roles",
"admin:clients"
]
}
Token verification guide
Local JWT verification is the most efficient way to validate tokens. Instead of calling the /oauth/introspect endpoint on every request, your service can verify the token signature using the public key from the JWKS endpoint.
Verification steps
- Fetch the JWKS from
/.well-known/jwks.jsonand cache the keys. - Decode the JWT header (without verifying) to extract the
kid(key ID). - Find the matching key in your cached JWKS by
kid. - Verify the signature using the RSA public key and the RS256 algorithm.
- Validate the claims: check
exp(not expired),iss(matches ZB ID issuer), andscopes(has the required permissions).
When to use local verification vs introspection
| Scenario | Recommended approach |
|---|---|
| High-traffic endpoints | Local verification (avoids network overhead) |
| Real-time token revocation needed | Introspection (checks current status) |
| Stateless microservices | Local verification (no external dependency) |
| Admin or sensitive operations | Introspection (confirms token is not revoked) |
| Development and debugging | Introspection (returns full decoded claims) |
For most production workloads, local verification with periodic JWKS refresh provides the best balance of performance and security. Use introspection as a fallback or for operations where you need to confirm the token has not been revoked since issuance.
Python (PyJWT + jwcrypto)
import jwt
import requests
from jwt import PyJWKClient
ZB_ID_URL = 'https://id.zb.co.zw'
ZB_ID_ISSUER = ZB_ID_URL
JWKS_URL = f'{ZB_ID_URL}/.well-known/jwks.json'
# Create a JWKS client (caches keys automatically)
jwks_client = PyJWKClient(JWKS_URL)
def verify_token(token: str) -> dict:
"""Verify a ZB ID JWT and return its claims."""
# Get the signing key from JWKS
signing_key = jwks_client.get_signing_key_from_jwt(token)
# Decode and verify the token
claims = jwt.decode(
token,
signing_key.key,
algorithms=['RS256'],
issuer=ZB_ID_ISSUER,
options={
'verify_exp': True,
'verify_iss': True,
'verify_aud': False,
}
)
return claims
def require_scope(token: str, scope: str) -> dict:
"""Verify a token and check for a required scope."""
claims = verify_token(token)
scopes = claims.get('scopes', [])
if scope not in scopes:
raise PermissionError(
f'Token missing required scope: {scope}'
)
return claims
# Usage
try:
claims = require_scope(token, 'banking:read')
user_id = claims['sub']
kyc_tier = claims.get('kycTier')
except jwt.ExpiredSignatureError:
print('Token has expired')
except jwt.InvalidTokenError as e:
print(f'Invalid token: {e}')
except PermissionError as e:
print(f'Access denied: {e}')
Node.js (jose)
import * as jose from 'jose'
const ZB_ID_URL =
'https://id.zb.co.zw'
const ZB_ID_ISSUER = ZB_ID_URL
const JWKS_URL =
`${ZB_ID_URL}/.well-known/jwks.json`
// Create a remote JWKS set (caches keys)
const JWKS = jose.createRemoteJWKSet(
new URL(JWKS_URL)
)
async function verifyToken(token) {
const { payload } = await jose.jwtVerify(
token,
JWKS,
{
issuer: ZB_ID_ISSUER,
algorithms: ['RS256'],
}
)
return payload
}
async function requireScope(token, scope) {
const claims = await verifyToken(token)
const scopes = claims.scopes || []
if (!scopes.includes(scope)) {
throw new Error(
`Token missing required scope: ${scope}`
)
}
return claims
}
// Usage in Express
async function authMiddleware(scope) {
return async (req, res, next) => {
const token = req.headers.authorization
?.replace('Bearer ', '')
if (!token) {
return res.status(401).json({
result: 'failed',
message: 'No token provided',
})
}
try {
const claims = await requireScope(
token,
scope
)
req.userId = claims.sub
req.scopes = claims.scopes
req.kycTier = claims.kycTier
next()
} catch (err) {
if (err.code === 'ERR_JWT_EXPIRED') {
return res.status(401).json({
result: 'failed',
message: 'Token has expired',
})
}
return res.status(403).json({
result: 'failed',
message: err.message,
})
}
}
}
// Apply to routes
app.get(
'/accounts/:id',
await authMiddleware('banking:read'),
(req, res) => {
// req.userId and req.scopes are available
}
)
Key rotation handling: Both PyJWKClient and jose.createRemoteJWKSet handle key rotation automatically. When they encounter a kid that is not in the cache, they re-fetch the JWKS. You do not need to implement manual key rotation logic.