Resource servers and tokens
Issue audience-restricted JWT access tokens for your APIs, verify them offline, bind them to keys with DPoP, and delegate calls with token exchange.
In OAuth, a resource server is simply an API that accepts access tokens: your invoices API, your reports API, your MCP server. The OAuth/OIDC provider issues the tokens; the resource server checks them on every request.
The problem it solves. A plain access token is valid wherever it is presented. If one API receives a token and that API is compromised or buggy, it could replay the token against every other API. And an API that has to ask the authorization server about every token adds a network call to every request. This page covers the four standards that fix those problems, in plain terms:
- Resource indicators (RFC 8707): the client says which API it wants a token for, and gets a token that only that API accepts.
- JWT access tokens (RFC 9068): the token is a JSON Web Token (JWT), a signed JSON document the API verifies locally with the provider's public key. No network call per request.
- DPoP (RFC 9449, "Demonstrating Proof of Possession"): the token is bound to a key the client holds, so a stolen token is useless without that key.
- Token exchange (RFC 8693): one API calls another on the user's behalf with a new, narrower token that records both who the user is and which API is acting.
Who configures it. Your team declares the APIs in the provider configuration and adds token verification to each API. Tenant administrators decide which client applications may call which API when they register clients.
Declare resource servers
Tell the provider which APIs exist, so it can issue tokens restricted to one of them. Add a resourceServers entry
per API:
import { createOAuthProvider } from 'better-iam/oauth';
const issuer = createOAuthProvider({
...options,
resourceServers: {
'https://api.example': { scopes: ['invoices:read', 'invoices:write'], accessTokenTtl: 600 },
'https://reports.example': { scopes: ['reports:read'] },
},
});Each key is a resource indicator: an absolute URI, without a query or fragment, that names the API. It usually is the API's base URL.
Prop
Type
Register clients for a resource
Being declared is not enough: a client may only get tokens for the resources it was registered with. Anything else
fails with invalid_target. This is how you decide, per application, which APIs it may call.
await issuer.registerClient(credential, {
tenantId,
clientId: 'billing-sync',
name: 'Billing sync',
redirectUris: [],
grantTypes: ['client_credentials'],
serviceAccountId,
scopes: ['invoices:read'],
resources: ['https://api.example'],
requireDpop: true,
});Request a resource token
A client asks for an API's token by adding the resource parameter. It then gets an access token restricted to
that audience, carrying only that API's scopes, in JWT format unless the server says accessTokenFormat: 'opaque'.
Without resource, OpenID requests keep receiving UserInfo access tokens, which are only good for the provider's
userinfo endpoint.
POST /oidc/token
Authorization: Basic <client credentials>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&resource=https://api.example&scope=invoices:readVerify tokens in your API
Every request to your API must prove it carries a valid token for this API with the right scopes.
createAccessTokenVerifier checks JWT access tokens inside your API, without calling the provider. For
client-credentials tokens it also reports the the client acts
as:
import { createAccessTokenVerifier } from 'better-iam/oauth';
const verifier = createAccessTokenVerifier({
issuer: 'https://identity.example/oidc',
audience: 'https://api.example',
});
const token = await verifier.verifyRequest(
{
authorization: req.headers.authorization,
dpop: req.headers.dpop,
method: req.method,
url: fullUrl,
},
{ scopes: ['invoices:read'] },
);
// token.tenantId, token.clientId, token.identityId (service account), token.subject, token.scopesBy default the verifier fetches and caches {issuer}/jwks, refreshing it on an unknown kid (key ID), so signing
key rollover needs no change in the API; pass jwks for pinned keys. It checks the signature, typ: at+jwt,
issuer, audience, expiry, and scopes. A missing scope fails with INSUFFICIENT_SCOPE (403); everything else fails
with INVALID_TOKEN (401).
The verifier has three functions:
verifyRequest(request, { scopes })checks theAuthorizationheader of a request, including the DPoP proof when the token is key-bound. Use it for every API request.verify(token, { scopes })checks a bare token string. It refuses DPoP-bound tokens, because it has no proof to check, so use it only where tokens arrive outside HTTP headers.challenge(error, realm?, { resourceMetadata?, scopes? })builds theWWW-Authenticateresponse header, which tells the client why it was refused. The optional arguments add a realm name, the protected resource metadata URL, and the scopes a403needs.
A complete route handler:
import { IamError } from 'better-iam';
export async function GET(request: Request) {
try {
const token = await verifier.verifyRequest(
{
authorization: request.headers.get('authorization'),
dpop: request.headers.get('dpop'),
method: request.method,
url: request.url,
},
{ scopes: ['invoices:read'] },
);
return Response.json(await listInvoices(token.tenantId!, token.subject));
} catch (error) {
if (!(error instanceof IamError)) throw error;
return new Response(null, {
status: error.status,
headers: { 'www-authenticate': verifier.challenge(error) },
});
}
}createAccessTokenVerifier takes these options:
Prop
Type
A verified token has these fields:
Prop
Type
Opaque tokens (accessTokenFormat: 'opaque') cannot be verified offline and need the provider's introspection
endpoint, which answers only for tokens issued to the calling client. Keep the default JWT format for APIs that
other clients call. Either way, the token only says who is calling and with which scopes: check the
and apply your product's authorization .
DPoP
An ordinary ("bearer") token works for whoever holds it, like cash. DPoP makes it work only together with a private key the client generated and never shares. The client signs a small proof for every request (method, URL, time, and a hash of the token), and the API checks the proof against the key the token is bound to. Use it for clients whose tokens could leak through logs, proxies, or compromised devices.
DPoP is on at the provider: a client that sends a DPoP proof receives a key-bound token (token_type: DPoP), and
a client registered with requireDpop cannot get anything else.
In the API, verifyRequest requires the DPoP authorization scheme and a proof for bound tokens. It checks the
key thumbprint, method, URL (ignoring query and fragment), access-token hash, and age, and rejects replayed proofs.
A plain bearer token sent with the DPoP scheme is refused too.
Replay detection is per instance
The verifier keeps replay state in memory per instance. A deployment with several API replicas should also
enforce short proof lifetimes with dpopMaxAge.
At the authorization server, dpopNonceSecret (32 bytes, base64, identical on every instance) enables
server-provided nonces: the server hands out a fresh value that the next proof must include, which limits how long
a pre-generated proof stays usable.
Token exchange
Sometimes an API needs to call another API for the same user. The invoices API, handling Ada's request, needs Ada's reports. Forwarding Ada's token does not work, because it is meant only for the invoices API. Using the invoices API's own service identity would lose track of who the request is for. Token exchange gives the invoices API a new token for the reports API that still names Ada as the subject and names the invoices API as the actor.
Register the calling API as a confidential client with the urn:ietf:params:oauth:grant-type:token-exchange grant
type, the downstream resources, and the scopes it may use there:
await issuer.registerClient(credential, {
tenantId,
clientId: 'invoices-api',
name: 'Invoices API',
redirectUris: [],
grantTypes: ['urn:ietf:params:oauth:grant-type:token-exchange'],
scopes: ['reports:read'],
resources: ['https://reports.example'],
});Then exchange the caller's token:
POST /oidc/token
Authorization: Basic <client credentials>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the caller's access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&resource=https://reports.example
&scope=reports:readThe issued token keeps the account as sub, names the exchanging client as client_id and as the actor in act
(nested for chains), carries only scopes both the client and the target allow, and expires no later than the
subject token. createAccessTokenVerifier reports the chain as actor. authorizeTokenExchange(request) on the
provider adds your own rule after the built-in checks. Each exchange is audited as iam:oauth:TokenExchange.
Token exchange has the complete walkthrough: every request parameter, the
claims of the issued token, the rules on subject tokens, the TokenExchangeRequest fields, and each error.
Endpoints
The provider endpoints a resource server and its clients use, relative to the issuer URL:
| Endpoint | Path | Discovery field | Used for |
|---|---|---|---|
| Token | POST /token | token_endpoint | Getting resource tokens (resource parameter) and token exchange. |
| Public keys | GET /jwks | jwks_uri | Offline JWT verification; createAccessTokenVerifier fetches it by default. |
| Introspection | POST /token/introspection | introspection_endpoint | Checking opaque tokens. Answers only for tokens issued to the calling client. |
| Revocation | POST /token/revocation | revocation_endpoint | A client cancelling its own token. |
| Discovery | GET /.well-known/openid-configuration | Finding the above. |
Errors
The verifier's verify and verifyRequest throw IamError (createResourceGuard's check turns the same failures
into 401 and 403 responses instead):
| Code | Status | When |
|---|---|---|
INVALID_TOKEN | 401 | Missing or malformed Authorization header, a bad signature, wrong typ, issuer, or audience, an expired token, a token of another tenant than the verifier's tenantId, or a DPoP proof that is missing, invalid, replayed, or for another key, method, or URL. |
INSUFFICIENT_SCOPE | 403 | The token lacks a scope passed in { scopes }. |
At the provider's token endpoint, a client asking for a resource it is not registered for receives the OAuth error
invalid_target. Answer verifier failures with verifier.challenge(error) in WWW-Authenticate, so clients learn
why they were refused.
Next steps
Better IAM is created by Sean Filimon
Last updated
OAuth/OIDC provider
Run a tenant-aware OAuth 2.0 and OpenID Connect authorization server for your own apps, CLIs, devices, and service accounts.
Token exchange
Let one of your APIs trade a caller's access token for a narrower token to another API, keeping the person as subject and recording the calling API as actor (RFC 8693).