Service tokens
When one of your backends needs to call another of your backends (no user in the loop), use the client_credentials grant. The result is a short-lived JWT signed by INITE that the receiving service verifies with the public JWKS — no shared secret between the two backends.
TL;DR: One OAuth client per calling service. Each client has scopes (what it can do) and an audience (who it's calling). Mint a token at request time and forward it as
Authorization: Bearer ….
Why not just share a static API key?
- Rotation: a leaked static key requires re-issuing it on every consumer. Service tokens expire in minutes; a leak is bounded.
- Audit: every mint is logged with
clientId,scope,audience,ip— searchable per tenant. - Scope: the same client can mint tokens with reduced privileges per call.
- Resource server isolation: setting
audiencebinds the token to one specific receiver — a token for service A can't be replayed at service B.
Step 1 — create a service client
In the admin panel at /admin → OAuth Clients:
- New client → name it after the calling service (e.g.
smart-chat-worker). - Grants: enable
client_credentialsonly (noauthorization_code). - Scopes: list what this caller is allowed to request (e.g.
chat:read chat:write). - Audiences: list the receiving services that accept this token (e.g.
chat-api). If you leave it empty, the audience defaults to theclientId. - Save → copy the
clientIdand the one-timeclientSecret.
The secret is shown once. Store it in your calling service's secret manager.
Step 2 — mint a token at request time
Inside INITE (use MachineTokenClient from @inite/auth)
If your service is part of the INITE monorepo, the shared @inite/auth package already wraps this — caching, proactive refresh, and concurrent-safe single-flight in one line:
import { MachineTokenClient } from '@inite/auth'
const tokens = new MachineTokenClient({
clientId: process.env.SMART_CHAT_WORKER_CLIENT_ID!,
clientSecret: process.env.SMART_CHAT_WORKER_CLIENT_SECRET!,
audience: 'chat-api',
scopes: ['chat:read', 'chat:write'],
// authServiceUrl defaults to AUTH_SERVICE_URL env (or https://auth-api.inite.ai)
})
const accessToken = await tokens.getAccessToken()
await fetch('https://app-api.example.com/v1/messages', {
headers: { Authorization: `Bearer ${accessToken}` },
})
The client caches the token until 60s before expiry, then refreshes once for all concurrent callers — your N parallel requests share one in-flight mint, not N.
Outside INITE (raw HTTP)
For partners / external services, hit the token endpoint directly:
curl -X POST https://auth.inite.ai/v1/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=smart-chat-worker' \
-d 'client_secret=<from step 1>' \
-d 'scope=chat:read chat:write' \
-d 'audience=chat-api'
Response:
{
"access_token": "eyJhbGc…",
"token_type": "Bearer",
"expires_in": 300,
"scope": "chat:read chat:write"
}
Default M2M TTL is 5 minutes — match your call's typical wall-time, mint on demand. Don't cache for hours.
Step 3 — verify on the receiving side
The receiving service has the OIDC public keys via JWKS. Always check the signature — not just decoding the JWT body — or any attacker that can write the token can write whatever claims they like.
Inside INITE
@inite/auth exports a verified-decoder via verifyAccessToken() (uses jose + cached JWKS under the hood):
import { verifyAccessToken } from '@inite/auth'
// In your Express/Next.js/Nest middleware:
const payload = await verifyAccessToken(bearer, {
audience: 'chat-api',
// issuer defaults to OIDC_ISSUER env / https://auth-api.inite.ai
})
// payload.sub = companyId or clientId
// payload.scope = "chat:read chat:write"
If the token's signature doesn't validate against any current JWKS key, verifyAccessToken throws. If audience doesn't match, it throws.
Note:
decodeJWT()exists in@inite/authfor inspection only — it does not check the signature. Never trust its output for authorization decisions.
Outside INITE
import { createRemoteJWKSet, jwtVerify } from 'jose'
const jwks = createRemoteJWKSet(
new URL('https://auth.inite.ai/.well-known/jwks.json'),
)
export async function verifyServiceToken(bearer: string) {
const { payload } = await jwtVerify(bearer, jwks, {
issuer: 'https://auth-api.inite.ai',
audience: 'chat-api', // matches what we minted with
})
// payload.sub = caller (companyId or clientId)
// payload.scope = space-separated grants
return payload
}
createRemoteJWKSet caches keys in-memory and re-fetches on kid miss — handles JWKS rotation automatically.
If the token was minted with audience=other-service, jwtVerify throws — your service is safe from cross-audience replay.
Audience binding deep dive
Without an audience, an attacker who steals a chat:read token from service A can replay it against service B (also requiring chat:read). Binding aud=chat-api collapses the blast radius — the token only validates at the named receiver.
INITE enforces:
- If the client has no
allowedAudiences,audiencedefaults to theclientId. - If the client has
allowedAudiences, the requestedaudiencemust be in that list. - If no
audienceis requested butallowedAudiences[0]is set, that's the default. - The RFC 8707
resourceparameter is used as the audience whenaudienceis not sent and the client may have it (noallowedAudiences, or listed there); aresourceoutside the list is ignored, as it always was.audiencewins when both are sent.
Scope inheritance
Requested scopes are validated against the client's allowedScopes. Request:
- No scope → token gets all
allowedScopes. - Specific scopes → must all be in
allowedScopes, otherwise 400.
This lets one client mint reduced tokens for individual calls without re-registering.
Secret rotation with grace window
When rotating a leaked secret:
/admin → OAuth Clients → rotate secret— sets a grace window (default 10 min).- During the window both the current and the previous secret are accepted.
- Roll the new secret out to all caller instances (k8s rollout, etc).
- After the window, the previous secret is rejected.
This avoids a "rotation outage" where a fleet of callers using the old secret all fail at once.
Per-client throttling
The token endpoint keys per client_id, not per IP. A fleet of caller instances behind one load balancer doesn't starve unrelated clients. Default limit is 60 mints / minute per client — tune via THROTTLE_DEFAULT_LIMIT.
Admin shortcut: test a client
/admin → Service Tokens → pick a client → paste secret → "Mint test token". You see the JWT, its decoded header + payload, and a ready-to-paste curl snippet. Use it to verify the integration before wiring it into your service.