Scenario D — Zero-trust microservices with Oathkeeper
End-to-end walkthrough: from nest new to a running three-service system where every downstream request is cryptographically verified via a signed JWT from Oathkeeper. No shared secrets. No network calls back to Kratos on the hot path. Signing-key rotation, audience scoping, envelope expiry, and anti-replay — all from config.
Everything here ships in ory-nestjs@0.4.0+. If you're on 0.2.x, the HMAC-based Oathkeeper mode is still there and behaves the same; the verifier: 'jwt' upgrade path is additive.
:::tip Skip the typing — clone the example
The three services, docker-compose.yml, Oathkeeper configs, and access rules shown below are all committed at TabsAndSpaces-Software-Solutions/ory-nestjs-example. git clone it and docker compose up if you'd rather see it running first, then read the rest of this page to understand each piece.
:::
:::note 0.4.0 fixes relevant to this scenario
Authorization: Bearer <jwt>is now accepted unchanged — the transport strips theBearerprefix forverifier: 'jwt'. Before 0.4.0 every request 401'd withauth.failure.invalid_signature.@RequirePermission(...)routes correctly read.data.allowedoff Keto's Axios response (instead of the incorrect.allowedon the raw response). Pre-0.4.0 every permission check 403'd even when Keto answeredallowed: true.TenantConfigis now the input shape (fields with defaults are optional). Consumers who factor tenants into a shared helper no longer have to declaresessionCookieNameand friends explicitly. For the post-validation shape use the newValidatedTenantConfigexport.- Ory image tags:
oryd/kratos:v1.3.1→oryd/kratos:v26.2.0(same for keto/hydra/oathkeeper). Docker Hub no longer hosts the old tags. - Oathkeeper v26 rejects
OPTIONSinserve.proxy.cors.allowed_methods; the providedconfig/oathkeeper.ymllistsGET, HEAD, POST, PUT, PATCH, DELETE. - Oathkeeper v26 does not auto-populate
jti; theid_tokenclaims template below explicitly declares"jti": "{{ uuidv4 }}"so the replay cache works. :::
What we're building
┌───────────────────────────────────────────────────┐
│ Browser / mobile / curl │
└────────────────────────┬──────────────────────────┘
│
http://localhost:4455
│
┌────────────────▼────────────────┐
│ Oathkeeper │
│ - reads session cookie / bearer │
│ - verifies with Kratos │
│ - signs id_token (JWT) │
│ - publishes JWKS at /jwks.json │
└───┬────────────┬─────────────────┘
│ │ │
/auth/… /orders/… /inventory/…
│ │ │
┌────────────▼─┐ ┌──────▼──────┐ ┌──▼─────────────┐
│ auth-bff │ │ orders-api │ │ inventory-api │
│ :3000 │ │ :3001 │ │ :3002 │
│ │ │ │ │ │
│ Kratos cookie│ │ Oathkeeper │ │ Oathkeeper JWT │
│ flows │ │ JWT verify │ │ + Keto permiss.│
│ (public) │ │ (zero-trust)│ │ + roles │
└──────────────┘ └─────────────┘ └────────────────┘
Trust boundary: orders-api and inventory-api never talk to Kratos. They trust a JWT signed by Oathkeeper's private key, which they verify against a JWKS fetched from Oathkeeper's management API. If the JWT is forged, expired, scoped for another service, or replayed — the request is 401'd before it touches your code.
Prerequisites
- Node 20+, Docker + Docker Compose v2,
pnpm(or npm/yarn). - The local Ory stack from Local Development Stack. Keep it running in a separate terminal — you'll need Kratos, Oathkeeper, MailSlurper, and (for Scenario D) Keto.
1. Generate Oathkeeper's JWKS (id_token signing key)
Oathkeeper needs an RSA/EC keypair to sign id_tokens. Generate it once; Oathkeeper uses the private half to sign and publishes the public half for consumers.
# In the directory that holds your docker-compose.yml + config/
docker run --rm -v "$PWD/config:/config" oryd/oathkeeper:v26.2.0 \
credentials generate --alg RS256 > config/oathkeeper-jwks.json
You now have config/oathkeeper-jwks.json with one RS256 key pair. Never commit this file — add it to .gitignore. Treat it like a database password.
2. Point Oathkeeper at the JWKS + enable the id_token mutator
Edit config/oathkeeper.yml — add id_token to the mutators block and raise it in the access rules so every downstream service receives a signed JWT:
mutators:
noop:
enabled: true
header:
enabled: true
config:
headers:
X-User: "{{ print .Subject }}"
# NEW — zero-trust: id_token signs a JWT per request.
id_token:
enabled: true
config:
issuer_url: http://oathkeeper:4455/
jwks_url: file:///etc/config/oathkeeper-jwks.json
ttl: 60s
claims: |
{
"tenant": "default",
"aud": ["orders-api", "inventory-api"],
"sub": "{{ print .Subject }}",
"jti": "{{ uuidv4 }}",
"metadataPublic": {{ print .Extra.metadata_public | toJson }}
}
aud— the services allowed to accept this token. Each NestJS service below will assert its own entry in this list.ttl: 60s— envelope expiry.ory-nestjs'sclockSkewMs(default 30 s) handles modest clock drift.jti: "{{ uuidv4 }}"— required for the replay cache to work. Oathkeeper v26 does NOT auto-populatejti; consumers who copy the template without this line will see every request after the first 401 withauth.failure.replay. Theuuidv4template function is built into Oathkeeper's claims evaluator.
Then update config/access-rules.json to wire three routes — one public, two zero-trust:
[
{
"id": "auth-bff:public",
"description": "Public auth routes — login, registration, recovery.",
"upstream": { "url": "http://host.docker.internal:3000" },
"match": {
"url": "http://127.0.0.1:4455/auth/<**>",
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
},
"authenticators": [{ "handler": "anonymous" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "orders-api:zero-trust",
"description": "Zero-trust route — Kratos session required, JWT minted.",
"upstream": { "url": "http://host.docker.internal:3001" },
"match": {
"url": "http://127.0.0.1:4455/orders/<**>",
"methods": ["GET", "POST", "PUT", "DELETE"]
},
"authenticators": [
{ "handler": "cookie_session" },
{ "handler": "bearer_token" }
],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "id_token" }]
},
{
"id": "inventory-api:zero-trust",
"description": "Zero-trust route — role-gated via the signed JWT's claims.",
"upstream": { "url": "http://host.docker.internal:3002" },
"match": {
"url": "http://127.0.0.1:4455/inventory/<**>",
"methods": ["GET", "POST", "PUT", "DELETE"]
},
"authenticators": [
{ "handler": "cookie_session" },
{ "handler": "bearer_token" }
],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "id_token" }]
}
]
Restart Oathkeeper:
docker compose restart oathkeeper
Sanity-check the JWKS endpoint:
curl -sS http://127.0.0.1:4456/.well-known/jwks.json | jq
You should see one key with kty: RSA, use: sig, alg: RS256. This is the URL every downstream service will fetch.
3. Scaffold three NestJS services
Pick any parent directory — the three services are independent apps.
npm i -g @nestjs/cli
nest new auth-bff --package-manager pnpm --skip-git
nest new orders-api --package-manager pnpm --skip-git
nest new inventory-api --package-manager pnpm --skip-git
Install ory-nestjs + peers in each:
for svc in auth-bff orders-api inventory-api; do
(cd "$svc" && pnpm add ory-nestjs reflect-metadata rxjs)
done
(NestJS 10+/11+ ships @nestjs/common, @nestjs/core, reflect-metadata, rxjs already — the pnpm add above is a no-op for those. ory-nestjs is the only new dep.)
4. auth-bff — the Kratos-facing service (cookie transport)
auth-bff sits behind Oathkeeper's anonymous rule and proxies Kratos self-service flows for browsers. No JWT here — users hit it anonymously to register and log in.
auth-bff/src/main.ts — bind to 3000 so Oathkeeper can reach it:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: 'http://localhost:4455', credentials: true });
await app.listen(3000);
}
bootstrap();
auth-bff/src/app.module.ts — global: false because this service should default to open (users aren't logged in yet):
import { Module } from '@nestjs/common';
import { IamModule } from 'ory-nestjs';
import { AuthController } from './auth.controller';
@Module({
imports: [
IamModule.forRoot({
global: false, // routes default to public
tenants: {
default: {
mode: 'self-hosted',
transport: 'cookie-or-bearer',
trustProxy: true,
kratos: {
publicUrl: 'http://127.0.0.1:4433', // Kratos public API
adminUrl: 'http://127.0.0.1:4434',
adminToken: 'local-dev-no-auth',
},
},
},
}),
],
controllers: [AuthController],
})
export class AppModule {}
auth-bff/src/auth.controller.ts:
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { FlowService, Public } from 'ory-nestjs';
@Controller('auth')
@Public()
export class AuthController {
constructor(private readonly flows: FlowService) {}
@Get('login')
initiateLogin(@Query('returnTo') returnTo?: string) {
return this.flows.forTenant('default').initiateLogin({ returnTo });
}
@Post('login/:flowId')
submitLogin(@Param('flowId') id: string, @Body() body: unknown) {
return this.flows.forTenant('default').submitLogin(id, body);
}
@Get('registration')
initiateRegistration() {
return this.flows.forTenant('default').initiateRegistration();
}
@Post('registration/:flowId')
submitRegistration(@Param('flowId') id: string, @Body() body: unknown) {
return this.flows.forTenant('default').submitRegistration(id, body);
}
}
Run it:
cd auth-bff && pnpm run start:dev
5. orders-api — zero-trust JWT consumer
This service never talks to Kratos. It trusts only the JWT minted by Oathkeeper. If you curl orders-api directly (bypassing Oathkeeper), every request 401s.
orders-api/src/main.ts — bind to 3001:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3001);
}
bootstrap();
orders-api/src/app.module.ts — this is the whole zero-trust config:
import { Module } from '@nestjs/common';
import { IamModule } from 'ory-nestjs';
import { OrdersController } from './orders.controller';
@Module({
imports: [
IamModule.forRoot({
tenants: {
default: {
mode: 'self-hosted',
transport: 'oathkeeper',
// Kratos still needs a publicUrl so the internal TenantClients
// bundle builds — but this service never reaches out to it on
// the request path.
kratos: { publicUrl: 'http://127.0.0.1:4433' },
oathkeeper: {
verifier: 'jwt', // asymmetric JWT mode
jwks: {
url: 'http://127.0.0.1:4456/.well-known/jwks.json',
algorithms: ['RS256'],
refreshIntervalMs: 600_000, // refetch every 10 min
cooldownMs: 30_000, // refetch-on-failure cooldown
},
audience: 'orders-api', // must match the id_token claim
clockSkewMs: 30_000, // expiry leeway
replayProtection: {
enabled: true, // use jti + replay cache
ttlMs: 120_000, // remember each jti 2 min
},
// identityHeader defaults to 'X-User'; match Oathkeeper's output.
identityHeader: 'Authorization', // id_token mutator writes here
signatureHeader: 'X-User-Signature', // unused in verifier=jwt
},
},
},
}),
],
controllers: [OrdersController],
})
export class AppModule {}
:::tip identityHeader: 'Authorization'
Oathkeeper's id_token mutator writes the JWT into the Authorization: Bearer <jwt> header by default. Setting identityHeader: 'Authorization' tells the library's transport where to read it. The library will strip a Bearer prefix automatically.
:::
orders-api/src/orders.controller.ts:
import { Controller, Get, Param, Post, Body } from '@nestjs/common';
import { CurrentUser, IamIdentity, RequirePermission } from 'ory-nestjs';
@Controller('orders')
export class OrdersController {
@Get()
list(@CurrentUser() user: IamIdentity) {
return { user: user.id, orders: [{ id: 'o-1' }, { id: 'o-2' }] };
}
@Get(':id')
@RequirePermission({
namespace: 'orders',
relation: 'view',
object: (req) => `orders:${req.params.id}`,
})
get(@Param('id') id: string, @CurrentUser() user: IamIdentity) {
return { id, owner: user.id };
}
@Post()
create(@Body() body: { sku: string }, @CurrentUser() user: IamIdentity) {
return { id: 'o-new', createdBy: user.id, ...body };
}
}
Run it:
cd orders-api && pnpm run start:dev
6. inventory-api — zero-trust + RBAC + Keto
Same transport config as orders-api, only the audience + controller differ. This service demonstrates role checks (@RequireRole) on a JWT claim and permission checks (@RequirePermission) against Keto.
inventory-api/src/main.ts:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3002);
}
bootstrap();
inventory-api/src/app.module.ts:
import { Module } from '@nestjs/common';
import { IamModule } from 'ory-nestjs';
import { InventoryController } from './inventory.controller';
@Module({
imports: [
IamModule.forRoot({
tenants: {
default: {
mode: 'self-hosted',
transport: 'oathkeeper',
kratos: { publicUrl: 'http://127.0.0.1:4433' },
keto: {
readUrl: 'http://127.0.0.1:4466',
writeUrl: 'http://127.0.0.1:4467',
},
oathkeeper: {
verifier: 'jwt',
jwks: {
url: 'http://127.0.0.1:4456/.well-known/jwks.json',
algorithms: ['RS256'],
refreshIntervalMs: 600_000,
cooldownMs: 30_000,
},
audience: 'inventory-api',
clockSkewMs: 30_000,
replayProtection: { enabled: true, ttlMs: 120_000 },
identityHeader: 'Authorization',
signatureHeader: 'X-User-Signature',
},
},
},
}),
],
controllers: [InventoryController],
})
export class AppModule {}
inventory-api/src/inventory.controller.ts:
import { Controller, Get, Param, Post, Body } from '@nestjs/common';
import {
CurrentUser,
IamIdentity,
Public,
RequirePermission,
RequireRole,
} from 'ory-nestjs';
@Controller('inventory')
export class InventoryController {
// Health is open for k8s/ELB probes.
@Get('health')
@Public()
health() {
return { status: 'ok' };
}
// Any authenticated user can read — no extra decorator needed;
// SessionGuard is global and satisfied by the Oathkeeper JWT.
@Get()
list() {
return [{ sku: 'sku-1', qty: 12 }];
}
// Only warehouse admins can adjust stock (role claim on the JWT).
@Post('adjust')
@RequireRole('warehouse:admin')
adjust(@Body() body: { sku: string; delta: number }) {
return { ok: true, ...body };
}
// Permission-scoped: can THIS user view THIS sku's ledger? Keto call.
@Get(':sku/ledger')
@RequirePermission({
namespace: 'listings',
relation: 'view',
object: (req) => `inventory:${req.params.sku}`,
})
ledger(@Param('sku') sku: string, @CurrentUser() user: IamIdentity) {
return { sku, viewer: user.id };
}
}
Run it:
cd inventory-api && pnpm run start:dev
7. End-to-end smoke test
With all three services running and the Ory stack up, walk the full zero-trust path via the Oathkeeper proxy on port 4455.
Register a user (goes through auth-bff via Oathkeeper)
# 1. Start a registration flow (non-browser / native).
curl -sS "http://127.0.0.1:4455/auth/registration" | jq '.id,.ui.action' -r
# copy the flow id from the first line, save the URL from the second.
# 2. Submit the flow with email + password.
FLOW_ID='<paste-flow-id>'
curl -sS -X POST "http://127.0.0.1:4455/auth/registration/$FLOW_ID" \
-H 'Content-Type: application/json' \
-d '{
"method": "password",
"password": "Corr3ctHorseBatteryStaple!",
"traits": { "email": "alice@example.com" }
}' | jq
Kratos returns a session token. Save it: export SESSION="$(... | jq -r .session_token)".
Hit orders-api through Oathkeeper (zero-trust path)
# Authenticated request → Oathkeeper mints a JWT → orders-api verifies.
curl -sS "http://127.0.0.1:4455/orders" -H "Authorization: Bearer $SESSION" | jq
# { "user": "<alice's identity id>", "orders": [...] }
Confirm direct access to orders-api fails
This is the zero-trust check — bypassing Oathkeeper MUST 401.
curl -sS -i "http://127.0.0.1:3001/orders" -H "Authorization: Bearer $SESSION" | head -3
# HTTP/1.1 401 Unauthorized
orders-api has no way to verify $SESSION directly — it only accepts JWTs signed by Oathkeeper.
Confirm audience scoping
If Oathkeeper mints a JWT with aud: ['orders-api', 'inventory-api'], both services accept it (when routed through their respective rules). If you try to use an orders-api JWT elsewhere, or trim the audience list in config, the other service 401s with audience_mismatch.
Confirm replay protection
Capture a JWT and replay it manually:
# Fetch once through Oathkeeper and capture the forwarded Authorization header.
CAPTURED=$(curl -sS -D - "http://127.0.0.1:4455/orders" \
-H "Authorization: Bearer $SESSION" 2>&1 | grep -i '^authorization:' || echo '')
# (If Oathkeeper doesn't forward the header — which is the default for id_token —
# capture it from tcpdump / a sniffer on the link. In a real attacker model,
# assume they can capture it in transit.)
# Replay with the SAME jti: the second request is refused.
curl -sS "http://127.0.0.1:3001/orders" -H "$CAPTURED" # ok
curl -sS -i "http://127.0.0.1:3001/orders" -H "$CAPTURED" | head -3
# HTTP/1.1 401 Unauthorized
The transport stored the JWT's jti in the in-memory ReplayCache. Subsequent calls with the same jti (within 120 s) are rejected with auth.failure.replay and a 401.
:::note Multi-pod deployments
The in-memory replay cache is process-local. If your NestJS service runs as more than one pod, an attacker can replay once per pod. Override the REPLAY_CACHE DI token with a Redis-backed implementation:
import { REPLAY_CACHE, type ReplayCache } from 'ory-nestjs';
@Injectable()
class RedisReplayCache implements ReplayCache {
constructor(private readonly redis: Redis) {}
async seen(jti: string) { return (await this.redis.get(`jti:${jti}`)) !== null; }
async remember(jti: string, ttlMs: number) {
await this.redis.set(`jti:${jti}`, '1', 'PX', ttlMs, 'NX');
}
}
@Module({
imports: [IamModule.forRoot({ /* … */ })],
providers: [{ provide: REPLAY_CACHE, useClass: RedisReplayCache }],
})
export class AppModule {}
:::
8. What you've got
Every request to orders-api and inventory-api now satisfies:
| Check | Who enforces it | What happens on failure |
|---|---|---|
| Valid RS256 signature | jose in ory-nestjs | 401 auth.failure.invalid_signature |
Not expired (exp + 30 s skew) | ory-nestjs transport | 401 auth.failure.expired |
| Audience matches this service | ory-nestjs transport | 401 auth.failure.audience_mismatch |
jti not previously seen (120 s) | ReplayCache | 401 auth.failure.replay |
| Tenant claim matches this request's tenant | SessionGuard | 401 auth.tenant_mismatch |
| Public/Public+Anonymous routes bypass all of it | SessionGuard | n/a — short-circuit return true |
@RequireRole(...) / @RequirePermission(...) | RoleGuard / PermissionGuard | 403 authz.role.deny / authz.permission.deny |
No Kratos round-trip on the hot path. No shared secret between Oathkeeper and your services. Key rotation = publish a new JWK at Oathkeeper's JWKS URL; ory-nestjs refreshes on cache miss or periodically.
9. Rotating the signing key
-
Add a second key to
config/oathkeeper-jwks.json:docker run --rm oryd/oathkeeper:v26.2.0 credentials generate --alg RS256 \| jq '.keys[0]' > /tmp/new-key.json# Merge into the existing JWKS keys[] array, keeping the old key for now.jq '.keys += [input]' config/oathkeeper-jwks.json /tmp/new-key.json \> config/oathkeeper-jwks.new.json && \mv config/oathkeeper-jwks.new.json config/oathkeeper-jwks.json -
Update
config/oathkeeper.ymlto reference the new key'skidas the signer (first entry by default). -
Restart Oathkeeper. It picks up the new JWKS; consumers pull the new JWK on their next refresh (≤ 10 min) or immediately on a cooldown-gated miss.
-
Drain in-flight tokens (≤ 60 s at
ttl: 60s). -
Remove the old key from JWKS, restart again.
No consumer restart, no config change on orders-api or inventory-api.
10. What to read next
- Authentication — the full decorator + guard surface.
- Self-service flows — browser vs native flows for
auth-bff. - Audit & observability — wiring the 19
auth.*/authz.*events into your logging / SIEM. - Error model — how
IamUnauthorizedError/IamForbiddenErrorbecome the Nest exceptions your clients receive. - Testing — spinning up
IamTestingModuleso you can unit-test controllers without booting Oathkeeper.