A realistic three-service topology where Authorizer is the single identity authority for both kinds of principals:
- Users authenticate with Authorizer (login/signup/OAuth) and present their JWT to the public gateway.
- Services are
service_accountclients in Authorizer's client registry and mint their own short-lived machine tokens with the OAuth2client_credentialsgrant (RFC 6749 §4.4) atPOST /oauth/token.
Node.js only — express for the servers, jose
(pinned) for JWT verification, built-in fetch for everything else. No hand-rolled
crypto.
PUBLIC ZONE │ INTERNAL ZONE
│
┌──────────┐ user JWT ┌───────────────┐ │ gateway machine token ┌────────────────┐
│ end user │ ───────────▶ │ gateway │ ──┼─────────────────────────▶ │ orders-service │
│ (browser │ Bearer ... │ :4000 │ │ scope: orders:write │ :4001 │
│ or app) │ │ │ │ + X-User-Id header │ │
└──────────┘ │ verifies user │ │ │ verifies │
│ │ JWT via JWKS │ │ │ machine JWT │
│ login / └───────┬───────┘ │ └───────┬────────┘
│ signup │ │ │
▼ │ │ orders machine token │
┌────────────────────────┐ │ │ scope: billing:charge │
│ Authorizer │ │ │ ▼
│ :8080 │ ◀──────┘ │ ┌────────────────┐
│ │ GET /.well-known/ │ │ billing-service│
│ /oauth/token │ jwks.json │ │ :4002 │
│ (client_credentials) │ ◀─────────────────────────────────────────────│ │
│ /.well-known/jwks.json│ POST /oauth/token (form-encoded) │ verifies │
│ /graphql (admin API) │ │ │ machine JWT │
└────────────────────────┘ │ └────────────────┘
Trust boundaries
- Edge (public → gateway). Anything past this line requires a user JWT the
gateway has verified locally: signature against Authorizer's JWKS, plus
issandaudclaims. No network round-trip to Authorizer per request. - Internal mesh (gateway → orders → billing). User tokens never cross this
line. Every internal hop is authenticated by the calling service's own
machine token, verified the same way plus two machine-specific checks:
login_method === "service_account"and the required scope in thescopeclaim. User context travels asX-User-Id/X-User-Emailheaders, which services accept only alongside a valid machine token. - Authorizer itself. The only holder of the signing private key and the
admin secret. Services hold nothing but their own
client_id/client_secret.
Why each service gets its own identity and scope ceiling
Each service is registered with a distinct allowed_scopes allow-list — its
authorization ceiling. A client_credentials request for any scope outside the
ceiling fails with invalid_scope; there is no silent downgrade.
| Service | allowed_scopes (ceiling) |
Can therefore reach |
|---|---|---|
example-gateway |
orders:read, orders:write |
orders-service only |
example-orders-service |
billing:charge |
billing-service charges only |
example-billing-service |
billing:refund (reserved) |
nothing yet |
The payoff is blast-radius containment, provable per token: if orders-service is
compromised, the attacker holds credentials that can mint only
billing:charge tokens — they cannot read orders, cannot refund, and cannot
impersonate the gateway. The demo proves both failure modes end-to-end: billing
rejects a gateway token (403 insufficient scope), and Authorizer refuses to even
mint billing:refund for the orders identity (400 invalid_scope). Shared
"internal API key" setups can't express either property.
Claims verified by the services (all confirmed against the server source):
| Claim | User token | Machine token |
|---|---|---|
iss |
Authorizer URL as seen by the token request | same |
aud |
the instance's global --client-id |
same — not the service's own client_id |
sub |
user ID | the service account's internal id (a UUID) |
scope |
JSON array of strings | JSON array — e.g. ["billing:charge"] |
login_method |
login method (e.g. basic_auth) |
always service_account |
Two easy-to-miss details, handled in lib/auth.js:
scopeis an array, not the space-delimited string OAuth responses use.- The issuer pitfall: Authorizer derives
issfrom the incoming request's host. If users get tokens viahttp://localhost:8080but a service verifies withEXPECTED_ISSUER=http://authorizer:8080, verification fails withERR_JWT_CLAIM_VALIDATION_FAILED (iss). Keep every party on one URL (the docker-compose file does), or setEXPECTED_ISSUERexplicitly.
Machine tokens carry no id_token and no refresh_token — services simply
re-authenticate on expiry (lib/auth.js caches until 30s before exp).
Prereqs: Node ≥ 20.12, and an Authorizer server from main:
# in the authorizer server repo
make dev # SQLite + dev RSA keys on :8080, admin secret "admin"Then, in this folder:
npm install
# 1. Register the three service_account clients (admin GraphQL,
# x-authorizer-admin-secret header) and write .env with the credentials.
# Secrets are returned exactly once by the API — .env is their only copy.
AUTHORIZER_ADMIN_SECRET=admin node setup.js
# 2. Start the services (three terminals, or background them)
npm run start:billing &
npm run start:orders &
npm run start:gateway &
# 3. End-to-end demo: signup user → order → charge → negative scope checks
npm run demoExpected demo output (abridged):
1) signing up demo user demo.user+...@example.com
2) POST /api/orders (user token)
201 { id: 'ord_1', ..., status: 'charged', charge_id: 'ch_1' }
3) GET /api/orders (user token) 200 [...]
4) GET /api/orders without token -> 401 (expect 401)
5) gateway token vs billing /charges -> 403 (expect 403)
6) orders requests billing:refund -> 400 invalid_scope (expect 400 invalid_scope)
All checks passed.
docker compose up -d authorizer gateway orders billing
docker compose run --rm setup # registers clients, writes .env
docker compose restart gateway orders billing # pick up the new .env
docker compose exec gateway node scripts/demo.js # run from INSIDE the networkThe authorizer image is a placeholder — build it from main first
(make build-local-image in the server repo) and match the image name in
docker-compose.yml. The demo must run inside the
network so the user token's iss is http://authorizer:8080 like everyone
else's (see the issuer pitfall above).
setup.js registers 3 service_account clients via admin GraphQL, writes .env
lib/auth.js JWKS verify (jose), scope middleware, cached client_credentials fetcher
gateway/server.js public edge: verifies USER JWTs, calls orders with its machine token
orders-service/server.js verifies gateway's machine token, charges via billing with its own
billing-service/server.js verifies orders' machine token + billing:charge scope
scripts/demo.js end-to-end proof incl. negative scope tests
docker-compose.yml whole topology on one network
All secrets in this example (admin, the dev client id, .env.example values)
are development placeholders — never deploy them.