BetterIAM

Protect an API with OAuth

Run Better IAM as the OAuth authorization server, declare a separate API as a resource server, issue audience-restricted access tokens, and verify them with scopes in a Hono or Express service.

In this guide you turn the Better IAM instance from the Quickstart into an OAuth 2.0 authorization server and protect a second service, an invoices API that runs in its own process, with the access tokens it issues. The API never calls Better IAM per request: it verifies signed JWT access tokens locally and checks their scopes. You will build:

  • an OAuth provider mounted at http://localhost:3000/oauth, with persistent keys and a consent page;
  • a resource server declaration for the invoices API (http://localhost:4000/api) with two scopes, invoices:read and invoices:write;
  • a machine-to-machine client that acts as a and gets tokens with the client credentials grant;
  • the invoices API in Hono (or Express), with one middleware that verifies tokens and requires a scope per route;
  • a browser client that gets tokens for a signed-in person with the authorization code flow.

Prerequisites

  • The project from the Quickstart: better-iam.config.mjs, iam.ts, a migrated database, and the organization Acme with an owner who signs in with a password. Export these variables in the shell you run the scripts from:

    export ACME_TENANT_ID=…          # the tenant.id of Acme
    export ACME_OWNER_EMAIL=owner@acme.test
    export ACME_OWNER_PASSWORD='another long password'
  • Node.js 22.12 or newer, and tsx (or any TypeScript runner) for the scripts.

  • curl to test the API.

Better IAM packages the provider as @better-iam/oauth, also exposed by the umbrella package as better-iam/oauth. Every import in this guide uses the umbrella.

Generate the provider keys

The provider needs three kinds of key material, and all of them must survive restarts and be identical on every replica:

KeyUsed for
jwksPrivate signing keys for ID tokens and JWT access tokens. The API verifies tokens with their public half.
cookieKeysSign the provider's own interaction and session cookies. At least 32 characters each.
encryptionKeyBase64-encoded 32-byte key that encrypts client secrets and protocol records at rest.

Generate them once and keep the file out of version control:

identity/scripts/generate-oauth-keys.mjs
import { generateKeyPairSync, randomBytes, randomUUID } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';

const output = process.argv[2];
if (!output) throw new Error('Usage: node generate-oauth-keys.mjs ./oauth-keys.json');
const pair = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = { ...pair.privateKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid: randomUUID() };
await writeFile(
  resolve(output),
  JSON.stringify(
    {
      jwks: { keys: [jwk] },
      cookieKeys: [randomBytes(32).toString('base64url')],
      encryptionKey: randomBytes(32).toString('base64'),
    },
    null,
    2,
  ),
  { flag: 'wx', mode: 0o600 },
);
console.log(`Wrote ${resolve(output)}. Set OAUTH_KEY_FILE to this path.`);
node scripts/generate-oauth-keys.mjs ./oauth-keys.json
export OAUTH_KEY_FILE="$PWD/oauth-keys.json"

Never regenerate keys at startup

New signing keys invalidate every token already issued, new cookie keys end pending sign-ins, and a new encryption key makes stored protocol records unreadable. In production, load this material from your secret store.

Create the provider and declare the API

createOAuthProvider builds the authorization server. iam.protocolHost supplies its database access, credential checks, permission checks, and session validation. resourceServers declares each API that accepts its tokens, keyed by a resource indicator (RFC 8707): an absolute URI without query or fragment that names the API. Tokens requested for it carry it as their audience (aud) and contain only the scopes listed for it.

identity/oauth.ts
import { readFileSync } from 'node:fs';
import { createOAuthProvider } from 'better-iam/oauth';
import { iam } from './iam';

const keys = JSON.parse(readFileSync(process.env.OAUTH_KEY_FILE!, 'utf8'));
export const origin = 'http://localhost:3000';
export const invoicesApi = 'http://localhost:4000/api';

export const issuer = createOAuthProvider({
  ...iam.protocolHost,
  issuer: `${origin}/oauth`,
  jwks: keys.jwks,
  cookieKeys: keys.cookieKeys,
  encryptionKey: keys.encryptionKey,
  trustedOrigins: [origin],
  allowInsecureLocalhost: true, // development only: allows http:// on localhost. Use HTTPS and remove it in production.
  resourceServers: {
    [invoicesApi]: { scopes: ['invoices:read', 'invoices:write'], accessTokenTtl: 600 },
  },
  interactionUrl: (uid) => `${origin}/interactions/${encodeURIComponent(uid)}`,
  renderDevicePage: ({ form }) => `<!doctype html><title>Device sign-in</title>${form}`,
  renderLogoutPage: ({ form }) => `<!doctype html><title>Sign out</title>${form}`,
});
OptionValue hereWhy
issuerhttp://localhost:3000/oauthWritten into every token as iss. It never changes after launch, and endpoint URLs keep its path: the token endpoint is http://localhost:3000/oauth/token, the public keys http://localhost:3000/oauth/jwks.
trustedOrigins['http://localhost:3000']Origins allowed to submit the consent form (completeInteraction), as a CSRF defense.
resourceServersthe invoices APIIts scopes are what tokens for it may carry; accessTokenTtl is the token lifetime in seconds (default 900). accessTokenFormat defaults to jwt, which the API can verify offline.
interactionUrl/interactions/{uid}Where the provider sends the browser when a person must sign in or consent. You build that page in a later step.
allowInsecureLocalhosttruePermits http:// URLs on loopback hosts. Local development only.

renderDevicePage and renderLogoutPage are required even if you never use the device flow or RP-initiated logout; they wrap the provider's CSRF-protected forms in your own HTML. See OAuth/OIDC provider for every option.

Mount the provider

iam.useProtocol(issuer) registers the provider, and iam.nodeHandler then serves everything under the issuer path (/oauth/...) plus the metadata document at /.well-known/oauth-authorization-server/oauth. The provider is a Node mount, so serve it with nodeHandler (not the fetch handler). The interaction route is yours:

identity/server.ts
import { createServer } from 'node:http';
import { iam } from './iam';
import { issuer, origin } from './oauth';
import { interaction } from './interactions';

iam.useProtocol(issuer);

createServer(async (req, res) => {
  const { pathname } = new URL(req.url ?? '/', origin);
  try {
    if (pathname.startsWith('/interactions/')) return await interaction(req, res);
    await iam.nodeHandler(req, res); // /api/iam, /oauth/*, /.well-known/oauth-authorization-server/oauth
  } catch (error) {
    if (!res.headersSent) res.writeHead(400, { 'content-type': 'text/plain' });
    res.end(error instanceof Error ? error.message : 'Request failed');
  }
}).listen(3000);

// Back-channel logout: end OAuth grants whose IAM session ended (sign-out, deactivation, expiry).
setInterval(() => void issuer.logoutEndedSessions().catch(() => undefined), 60_000).unref();

The interaction page has two halves. On GET, issuer.interactionDetails(req, res) says which client asks for which scopes and resources, and you render a consent screen. On POST, issuer.completeInteraction(req, res, { credential, consent }) records the answer and redirects the browser back to the client. The credential must be the person's real IAM session (their cookie), never an ID from the form.

identity/interactions.ts
import type { IncomingMessage, ServerResponse } from 'node:http';
import { iam } from './iam';
import { issuer } from './oauth';

function headersOf(req: IncomingMessage): Headers {
  const headers = new Headers();
  for (const [name, value] of Object.entries(req.headers))
    if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value);
  return headers;
}

async function readForm(req: IncomingMessage): Promise<URLSearchParams> {
  let body = '';
  for await (const chunk of req) body += chunk;
  return new URLSearchParams(body);
}

const escape = (value: string) =>
  value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

export async function interaction(req: IncomingMessage, res: ServerResponse): Promise<void> {
  const credential = { headers: headersOf(req) }; // the person's IAM session cookie
  const signedIn = await iam.authenticate(credential).then(
    () => true,
    () => false,
  );
  if (!signedIn) {
    // Send the person to your existing sign-in page, then back here.
    res.writeHead(303, { location: `/sign-in?next=${encodeURIComponent(req.url ?? '/')}` });
    res.end();
    return;
  }
  if (req.method === 'GET') {
    const details = await issuer.interactionDetails(req, res);
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
    res.end(`<!doctype html>
<h1>${escape(details.client.name)} wants access</h1>
<p>Scopes: ${details.scopes.map(escape).join(', ')}</p>
<p>APIs: ${details.resources.map(escape).join(', ')}</p>
<form method="post">
  <button name="consent" value="yes">Allow</button>
  <button name="consent" value="no">Deny</button>
</form>`);
    return;
  }
  const form = await readForm(req);
  await issuer.completeInteraction(req, res, { credential, consent: form.get('consent') === 'yes' });
}

/sign-in stands for the sign-in page your product already has (see Email and password). The person must be signed in to the client's ; otherwise completeInteraction fails with TENANT_MISMATCH.

Start the identity service and check the discovery document:

npx tsx server.ts
curl -s http://localhost:3000/.well-known/oauth-authorization-server/oauth | head -c 300

Register a machine-to-machine client

A nightly billing job has no person behind it, so it uses the client credentials grant. Such a client must act as an active service account in the same tenant, and its tokens name that service account as identity_id, so the API knows which non-human identity is calling. Registering a client needs iam:oauth:clients:create, which the organization owner has.

identity/scripts/register-billing-client.ts
import { iam } from '../iam';
import { invoicesApi, issuer } from '../oauth';

const tenantId = process.env.ACME_TENANT_ID!;
const signIn = await iam.api.auth.signIn({
  tenantId,
  email: process.env.ACME_OWNER_EMAIL!,
  password: process.env.ACME_OWNER_PASSWORD!,
});
if ('mfaRequired' in signIn) throw new Error('This owner needs MFA: complete it as the quickstart root did');
const owner = { token: signIn.token };

const billing = await iam.api.serviceAccounts.create(owner, {
  tenantId,
  name: 'Billing sync',
  description: 'Reads invoices every night',
});

const { clientId, clientSecret } = await issuer.registerClient(owner, {
  tenantId,
  clientId: 'billing-sync',
  name: 'Billing sync',
  redirectUris: [],
  grantTypes: ['client_credentials'],
  serviceAccountId: billing.id,
  scopes: ['invoices:read'], // the most this client may ever ask for
  resources: [invoicesApi], // the APIs it may get tokens for
});

console.log({ clientId, clientSecret }); // the secret is shown once: store it in the job's secret store
npx tsx scripts/register-billing-client.ts
export BILLING_CLIENT_SECRET=…   # clientSecret from the output

Two lists decide what this client can ever get: scopes (its maximum scopes) and resources (the APIs it may get tokens for). A token request for any other API fails with invalid_target, and scopes the API does not define are dropped from its tokens. iam.api.serviceAccounts.create is also available as POST /api/iam/serviceAccounts/create.

Verify tokens in the API

The invoices API is a separate service. It needs only the issuer URL and its own resource indicator. createAccessTokenVerifier fetches the provider's public keys from {issuer}/jwks, caches them, refetches on an unknown key ID (so key rollover needs no change here), and checks the signature, typ: at+jwt, issuer, audience, expiry, and the scopes you require.

npm i better-iam hono @hono/node-server
invoices-api/src/auth.ts
import { createAccessTokenVerifier } from 'better-iam/oauth';

export const verifier = createAccessTokenVerifier({
  issuer: 'http://localhost:3000/oauth', // exactly as in the provider's discovery document
  audience: 'http://localhost:4000/api', // this API's resource indicator
  // tenantId: process.env.ACME_TENANT_ID, // set it when this API serves one organization only
});

Each route names the scopes it needs. A missing or invalid token fails with INVALID_TOKEN (401) and a token without a required scope with INSUFFICIENT_SCOPE (403); verifier.challenge(error) builds the WWW-Authenticate header that tells the client which.

invoices-api/src/server.ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { createMiddleware } from 'hono/factory';
import { IamError } from 'better-iam/core';
import type { VerifiedAccessToken } from 'better-iam/oauth';
import { verifier } from './auth';

type Env = { Variables: { token: VerifiedAccessToken } };

const requireScopes = (...scopes: string[]) =>
  createMiddleware<Env>(async (c, next) => {
    try {
      const token = await verifier.verifyRequest(
        {
          authorization: c.req.header('authorization'),
          dpop: c.req.header('dpop'),
          method: c.req.method,
          url: c.req.url,
        },
        { scopes },
      );
      c.set('token', token);
    } catch (error) {
      if (!(error instanceof IamError)) throw error;
      return c.json(
        { error: error.code, message: error.message },
        error.status as 401 | 403,
        { 'www-authenticate': verifier.challenge(error, 'invoices', { scopes }) },
      );
    }
    await next();
  });

const invoices = [
  { id: 'inv_1', tenantId: process.env.ACME_TENANT_ID, amountCents: 12_000 },
];

const app = new Hono<Env>();

app.get('/api/invoices', requireScopes('invoices:read'), (c) => {
  const token = c.get('token');
  // Scope every query to the token's tenant. The token says who calls and with which scopes;
  // what that caller may see in your data is still your product's decision.
  return c.json(invoices.filter((invoice) => invoice.tenantId === token.tenantId));
});

app.post('/api/invoices', requireScopes('invoices:write'), async (c) => {
  const token = c.get('token');
  const body = await c.req.json<{ amountCents: number }>();
  const invoice = { id: `inv_${Date.now()}`, tenantId: token.tenantId, amountCents: body.amountCents };
  invoices.push(invoice);
  return c.json(invoice, 201);
});

serve({ fetch: app.fetch, port: 4000 });

A verified token has these fields:

FieldMeaning
subjectThe person (sub), or the client for client-credentials tokens.
clientIdThe application the token was issued to.
tenantIdThe tenant of the client and account. Scope your data access to it.
identityIdThe service account behind a client-credentials client.
scopesThe scopes granted for this API.
audience, expiresAt, issuedAt, tokenIdaud, exp, iat, and jti.
boundKeyFor a DPoP-bound token, the thumbprint of the key it is bound to.
actorFor an exchanged token, the client acting for subject (RFC 8693 act).
claimsEvery claim of the token.

Use verifyRequest for HTTP requests: it checks DPoP proofs when a token is key-bound. verifier.verify(token, { scopes }) checks a bare token string and refuses DPoP-bound tokens. Opaque tokens (accessTokenFormat: 'opaque') cannot be verified offline.

Prefer one call per request?

createResourceGuard from better-iam/oauth wraps the same verifier for fetch-style frameworks such as Hono: const { response, token } = await guard.check(c.req.raw, { scopes }) returns either a ready 401/403 response or the verified token, and also serves the API's protected resource metadata (RFC 9728). See Protect the MCP server.

Start the API in a second terminal:

cd invoices-api && npx tsx src/server.ts

Get a token and call the API

The billing job asks the token endpoint for a token restricted to the invoices API with the resource parameter, authenticating with its client ID and secret (client_secret_basic, the default):

TOKEN=$(curl -s -u "billing-sync:$BILLING_CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=http://localhost:4000/api \
  -d scope=invoices:read \
  http://localhost:3000/oauth/token | node -e "process.stdin.on('data', d => console.log(JSON.parse(d).access_token))")

curl -s http://localhost:4000/api/invoices -H "authorization: Bearer $TOKEN"

In code, the job does the same with fetch:

billing-job/token.ts
export async function invoicesToken(): Promise<string> {
  const response = await fetch('http://localhost:3000/oauth/token', {
    method: 'POST',
    headers: {
      authorization: `Basic ${btoa(`billing-sync:${process.env.BILLING_CLIENT_SECRET}`)}`,
      'content-type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      resource: 'http://localhost:4000/api',
      scope: 'invoices:read',
    }),
  });
  if (!response.ok) throw new Error(`Token request failed: ${response.status}`);
  const { access_token } = (await response.json()) as { access_token: string; expires_in: number };
  return access_token; // valid for 600 seconds (the resource server's accessTokenTtl); cache it until then
}

Let people call the API from an app

When a person uses a web or mobile app, the app gets tokens through the authorization code flow with PKCE: the person signs in to Better IAM, approves the app on your interaction page, and the app receives a token for the API. Register a public client (no secret; PKCE is required for every client) that may request the invoices API:

identity/scripts/register-web-client.ts
import { iam } from '../iam';
import { invoicesApi, issuer } from '../oauth';

const tenantId = process.env.ACME_TENANT_ID!;
const signIn = await iam.api.auth.signIn({
  tenantId,
  email: process.env.ACME_OWNER_EMAIL!,
  password: process.env.ACME_OWNER_PASSWORD!,
});
if ('mfaRequired' in signIn) throw new Error('This owner needs MFA');

await issuer.registerClient(
  { token: signIn.token },
  {
    tenantId,
    clientId: 'invoices-web',
    name: 'Invoices',
    public: true,
    redirectUris: ['http://localhost:5173/callback'],
    scopes: ['openid', 'offline_access', 'invoices:read', 'invoices:write'],
    resources: [invoicesApi],
  },
);

The app sends the person to the authorization endpoint with resource and a PKCE challenge, then exchanges the code with the same resource:

GET /oauth/auth?client_id=invoices-web&response_type=code
  &scope=openid%20offline_access%20invoices:read
  &resource=http://localhost:4000/api
  &redirect_uri=http://localhost:5173/callback
  &code_challenge=...&code_challenge_method=S256&state=...&prompt=consent
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&client_id=invoices-web&code=...&code_verifier=...
&redirect_uri=http://localhost:5173/callback&resource=http://localhost:4000/api

Use an OAuth client library such as oauth4webapi for the PKCE and state handling. The resulting access token has sub set to the person and tenant_id to their organization, and it works with the same API middleware. Without resource, an OpenID request receives a UserInfo access token, which the API rejects because its audience is not the API.

Consent is bound to the person's IAM : signing out, deactivation, or tenant suspension ends the app's refresh tokens. People list and disconnect their apps with issuer.listGrants and issuer.revokeGrant (Consent and connected apps).

Harden for production

SettingWhat to do
HTTPSUse an https:// issuer and resource indicators, and remove allowInsecureLocalhost. Behind a TLS-terminating proxy, set trustProxy: true.
Key materialLoad jwks, cookieKeys, and encryptionKey from your secret store; rotate signing keys by publishing the new key alongside the old ones.
Sender-constrained tokensRegister high-value clients with requireDpop: true, so a leaked token is useless without the client's private key. The API middleware above already checks DPoP proofs. See DPoP.
One organization per APIPass tenantId to createAccessTokenVerifier when an API serves a single organization and does not filter by token.tenantId itself.
API-to-API callsUse token exchange instead of forwarding a person's token to another API.
Secret leaksissuer.rotateClientSecret(credential, { tenantId, clientId, revokeTokens: true }) issues a new secret and revokes tokens obtained with the old one.
Back-channel logoutKeep issuer.logoutEndedSessions() running on an interval and after sign-out events. See Protocol jobs.

Verify it works

With both services running and TOKEN set as in the previous step:

# 1. No token: 401 with a WWW-Authenticate challenge
curl -si http://localhost:4000/api/invoices | grep -i -E '^HTTP|www-authenticate'
# HTTP/1.1 401 Unauthorized
# www-authenticate: DPoP realm="invoices", error="invalid_token", algs="…", Bearer realm="invoices", error="invalid_token"

# 2. Read with the invoices:read token: 200 and Acme's invoices
curl -s http://localhost:4000/api/invoices -H "authorization: Bearer $TOKEN"
# [{"id":"inv_1","tenantId":"…","amountCents":12000}]

# 3. Write with the same token: 403, the scope is missing
curl -si -X POST http://localhost:4000/api/invoices -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"amountCents":500}' | grep -i -E '^HTTP|www-authenticate'
# HTTP/1.1 403 Forbidden
# www-authenticate: … error="insufficient_scope", scope="invoices:write" …

To confirm the audience check, register a second resource server in resourceServers, get a token for it, and send it to the invoices API: it is refused with INVALID_TOKEN, because its aud is the other API.

The provider audits client administration in the tenant's audit chain: iam.api.audit.list(owner, { tenantId, action: 'iam:oauth:RegisterClient' }) returns the registration of billing-sync.

Troubleshooting

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page