BetterIAM

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:

oauth.ts
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:read

Verify 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:

api/auth.ts
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.scopes

By 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 the Authorization header 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 the WWW-Authenticate response header, which tells the client why it was refused. The optional arguments add a realm name, the protected resource metadata URL, and the scopes a 403 needs.

A complete route handler:

app/api/invoices/route.ts
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:read

The 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:

EndpointPathDiscovery fieldUsed for
TokenPOST /tokentoken_endpointGetting resource tokens (resource parameter) and token exchange.
Public keysGET /jwksjwks_uriOffline JWT verification; createAccessTokenVerifier fetches it by default.
IntrospectionPOST /token/introspectionintrospection_endpointChecking opaque tokens. Answers only for tokens issued to the calling client.
RevocationPOST /token/revocationrevocation_endpointA client cancelling its own token.
DiscoveryGET /.well-known/openid-configurationFinding the above.

Errors

The verifier's verify and verifyRequest throw IamError (createResourceGuard's check turns the same failures into 401 and 403 responses instead):

CodeStatusWhen
INVALID_TOKEN401Missing 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_SCOPE403The 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

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page