Protect a JSON API and automate a grant report
Availability: this walkthrough describes a template available through
repository access arranged with Udibo. It is not a public standalone starter.
It needs an accessible Udibo Identity tenant. The template uses the published
@udibo/oauth2 package.
The template contains a Hono API, run by Node.js 26 from its TypeScript sources with no build step, that serves every customer organization in your tenant from one deployment, plus a separate command that reads grants on one report. Each caller's organization comes from their verified access token and scopes every query. The API stores reports in Postgres. The command uses its own machine credentials and an explicit administrator-assigned permission.
What it demonstrates
The template shows a member reading their organization's reports, another
organization's member seeing none of them, an organization whose slug spells
another's id seeing only its own, and a member with no role reading the one
report shared with them. For your own tests, the stand-in tenant
createFakeTenant from @udibo/oauth2/testing can register a machine
application: addClient with grants: ["client_credentials"], a secret,
scopes and machinePermissions: ["resource_grants.read"].
Register the API
Choose the API's origin, such as https://api.example.com. It is the token
audience for every route. Register the reports:read and reports:write
customer permissions, create roles containing them, and assign the roles to
people inside their organizations. Register the resource type report. Follow
roles and permissions for
that setup.
Register a public, first-party application with the authorization-code grant,
JWT access tokens, the API's origin as audience, and your client's callback URL.
Add http://127.0.0.1/callback too if you want a command-line tool to sign you
in from a browser and print a token; loopback redirect URIs match on any port.
Follow
application registration.
Your OAuth client must use PKCE S256 and state, request openid, and select the
organization using the hosted authorization request's organization slug
parameter.
Set these values in the API's environment:
UDIBO_ISSUER: your tenant origin, such ashttps://<tenant-id>.udibo.com.API_URL: the API's origin, written exactly as the registered audience with no path or trailing slash.DATABASE_URL: the Postgres database for reports.UDIBO_CLIENT_ID: the public application's client ID, for a command-line tool that signs you in and prints a token.PORT: optional; where the server listens,8011by default.
In your copy of the template, install, migrate the database and start it:
pnpm install --frozen-lockfile
cp .env.example .env # then fill it in
pnpm db:migrate
pnpm dev # http://localhost:8011pnpm access-token <organization-slug> is the command-line tool: it prints an
address to open in your browser, then an export TOKEN=… line for the
organization you name.
A missing or invalid value stops the API before it listens, with one line naming
every problem. The API reads configuration from the environment only.
Terminate HTTPS at the proxy and preserve the canonical Host header; /healthz
answers any host for load-balancer probes. HTTP URLs are accepted only for
loopback development. The API refuses cross-origin browser requests, so a
browser application calls it through its own server.
Checkpoint: sign in as a member of an organization, selecting it with the
organization slug parameter, and obtain an access token. GET /reports without a bearer token returns 401. With the token, a
permitted member gets 200 and their organization's ID and reports. An
unpermitted member gets 403, and so does a token that names no organization.
POST /reports with a JSON title returns 201 for a member holding
reports:write; the organization is always the token's.
Share one report
A member without reports:read in their organization can read one report once
an administrator grants them a role carrying reports:read on it: resource type
report, and the report's ID. The API asks your tenant's /api/check for that
report live, so revoking the grant ends access on the next request. Unknown
reports, other organizations' reports and reports the caller may not read all
answer the same 404.
Register the reporting bot
Create a separate confidential application with the client-credentials grant and
an explicit identity:organizations:read scope allowlist. Keep the token format
and audience at their tenant defaults. Generate a client secret and store it
only on the backend running the command.
A tenant Owner or Admin then assigns resource_grants.read in the dashboard: on
the application's detail page, under Machine permissions, check Read
resource grants and choose Save machine permissions. This is separate from
customer roles: registering a customer key of the same name confers no
management authority. See
resource-grant management for
who can assign it and how revocation works.
Add two values to the command's environment, in .env or the host's
environment:
UDIBO_BOT_CLIENT_ID: the reporting application's client ID.UDIBO_BOT_CLIENT_SECRET: its secret; never expose it to a browser.
The command shares UDIBO_ISSUER with the API. Run it with a report ID:
pnpm grant-report <report-id>. It
obtains its own machine token and reads /api/resource-grants on your tenant origin for that
report. It does not call the management API (/api/identity/*) with that token.
Checkpoint: stdout contains JSON naming the report and its grant holders. A
report nobody has been granted produces an empty grants array. Revoke the
bot's read permission and rerun: the command exits unsuccessfully, emits no
report, and says on stderr that the bot is not assigned the permission. An
unregistered resource type and rejected credentials each get their own
explanation. Protect successful output as authorization data. The command never
prints its credentials or token.
Extend the template safely
Replace the reports table and routes with your own, keeping every row keyed by
organization ID and every query scoped to the verified organization ID from the
token. New routes registered after the protected mount in server/app.ts are protected
by default, and the template's route test fails when a route outside its public
list answers without a token, or answers a member who holds no permission. It
calls each route that takes a parameter on a row that exists, because a route
with no permission check answers an unknown id with the same 404 as a refusal.
JWT signature, issuer, audience and expiry are checked locally; permission and
organization checks remain your application's responsibility. Local JWT
validation does not check immediate revocation, so access may last until the
token expires. Keep lifetimes short and clocks synchronized.
The reporting command reads grants only. It does not create organizations, invite members, or change sharing. Scheduling is an application choice. Use the production checklist before exposing your application.
Last verified 2026-10-13.

