Module 6 ended on an uncomfortable note: TechCorp's system is now observable, resilient, scalable and operable, but it is still not secure. The first crack is the most visible one: the gateway's requireToken (03-04) only checks that the Authorization header starts with Bearer, and no service yet knows who is calling. This lesson closes that crack: it explains the difference between authenticating and authorizing, why the monolith's in-memory session does not work in a distributed system, how OAuth 2.0 and OpenID Connect work in practice with Keycloak as the identity server, what is inside a JWT and how it is validated with the realm's public keys, and how you then decide whether Ana Ruiz may see order ord-88213. All the code extends what has already been built: the gateway from 03-04, the @techcorp/common-http library and orders-service. Anything that concerns channel encryption (07-02), input validation (07-03) and secrets in Kubernetes (07-04) is only mentioned.
Notice. The flows, configurations and decisions in this lesson are for teaching purposes. Before exposing a real system, the Keycloak configuration, token lifetimes and authorization policies must be reviewed with a security professional and, where personal or payment data is involved, with the compliance officer.
Contents
- Authentication versus authorization, and why the monolith's session does not work
- OAuth 2.0 and OpenID Connect in practice: roles and flows
- Anatomy of a JWT: structure, claims and signature with JWKS
- Keycloak for TechCorp: realm, clients, roles and custom claims
- Validation at the gateway: the complete
requireTokenwithjose - Defense in depth:
authenticate()in@techcorp/common-http - Authorization: roles, resource owner and scopes
- Service to service: client credentials and what happens with RabbitMQ
- 401 and 403 errors, logout and revocation
- Tests: tokens signed with a local key in Jest
- Authentication versus authorization, and why the monolith's session does not work
Two different questions that get mixed up every day:
| Authentication (authn) | Authorization (authz) | |
|---|---|---|
| Question | Who are you? | What are you allowed to do? |
| Answer | A verified identity: sub, roles, scopes |
Allowed / denied for this action on this resource |
| Who resolves it at TechCorp | Keycloak issues; gateway and services verify | Each service, with its own business rule |
| HTTP error on failure | 401 UNAUTHENTICATED |
403 FORBIDDEN |
In the techcorp-shop monolith, Ana logged in, Express stored a session in memory (or in Redis) and a connect.sid cookie identified her on every request. That does not work with seven processes: orders-service shares no memory with customers-service; a shared session in Redis couples every service to one store and one format, and every internal request would have to look it up. The alternative the whole industry has adopted is portable identity: a signed token that travels with every request and that any service can verify without asking anyone, using only the issuer's public key. That token is a JWT, and the protocol for obtaining it is OAuth 2.0 / OpenID Connect.
- OAuth 2.0 and OpenID Connect in practice: roles and flows
OAuth 2.0 is a framework for delegated authorization; OpenID Connect (OIDC) is the authentication layer on top of OAuth (it adds the ID token and the userinfo endpoint). In practice they are used together, with four roles:
| OAuth role | At TechCorp |
|---|---|
| Resource owner | Ana Ruiz (c-1024), a store operator, an administrator |
| Client | web-store (SPA), bff-mobile, orders-service when it calls another service |
| Authorization server | Keycloak, https://auth.techcorp.example/realms/techcorp |
| Resource server | The gateway and every *-service that exposes an API |
The flows (grants) TechCorp uses, and the one it does not:
| Flow | What for | Who uses it |
|---|---|---|
| Authorization Code + PKCE | People in a browser or app: redirect to Keycloak, the user authenticates there, a code comes back and is exchanged for tokens. PKCE (code_verifier/code_challenge) protects the exchange without needing a client secret |
web-store, bff-mobile |
| Client Credentials | Service to service, no user involved: client_id + client_secret in exchange for a token that represents the service |
orders-service → Catalog/Customers (section 8) |
| Refresh Token | Renew a short-lived access token without asking for the password again | All clients used by people |
| ~~Password grant~~ | The client collects username and password and sends them to Keycloak | Not used: the password passes through TechCorp code, it does not allow MFA or social login, and OAuth 2.1 removes it |
Ana's login and her first order, end to end:
sequenceDiagram
participant A as Ana (browser)
participant W as web-store (SPA)
participant KC as Keycloak (realm techcorp)
participant GW as Gateway 8080
participant P as orders-service 3002
A->>W: Clicks "Sign in"
W->>KC: GET /auth?response_type=code&client_id=web-store&code_challenge=…&scope=openid orders:create orders:read
KC->>A: Login form (and MFA if enabled)
A->>KC: username + password
KC-->>W: 302 …/callback?code=abc123
W->>KC: POST /token (code, code_verifier)
KC-->>W: access_token (5 min) + refresh_token (30 min) + id_token
A->>W: Clicks "Buy"
W->>GW: POST /api/v1/orders Authorization: Bearer <access_token>
GW->>GW: requireToken: signature, iss, aud, exp (cached JWKS)
GW->>P: POST /v1/orders + Authorization + X-User-Id + X-User-Roles
P->>P: authenticate() again, requireScope('orders:create'), customerId from the token
P-->>GW: 202 Location: /v1/orders/ord-88213
GW-->>W: 202
Two important details: Ana's password is seen only by Keycloak (the SPA never touches it), and the gateway does not call Keycloak on every request: it verifies the signature locally with the public key it downloaded once.
- Anatomy of a JWT: structure, claims and signature with JWKS
A JWT is three Base64URL parts separated by dots: header.payload.signature.
// Header: { "alg": "RS256", "typ": "JWT", "kid": "k1-2026-08" }
// Payload of Ana's access token, issued by Keycloak:
{
"iss": "https://auth.techcorp.example/realms/techcorp",
"sub": "3f2a9c1e-7b44-4d0a-9e51-2c8f0b6a1d77",
"aud": "techcorp-api",
"azp": "web-store",
"exp": 1786790400, "iat": 1786790100,
"scope": "openid orders:create orders:read",
"preferred_username": "ana.ruiz", "realm_access": { "roles": ["customer"] }, "customerId": "c-1024"
}
// Signature: RS256(base64url(header) + "." + base64url(payload), realm private key)iss(issuer),sub(stable identifier of the subject),aud(who the token is for),exp/iat(expiry and issuance, in Unix seconds) andscopeare standard claims (RFC 7519 and OAuth).azp(authorized party: the client that requested it),preferred_usernameandrealm_access.rolesare Keycloak's.customerIdis a custom claim that we will add in section 4.- The signature is RS256 (RSA + SHA-256, asymmetric): Keycloak signs with its private key; anyone verifies with the public one. The public keys are published in the JWKS (
JSON Web Key Set), whose URL appears in the discovery documenthttps://auth.techcorp.example/realms/techcorp/.well-known/openid-configuration(jwks_uri,token_endpoint,authorization_endpoint...). Thekidin the header says which key of the set to use, which is why Keycloak can rotate keys without breaking anything. - A JWT is not encrypted: anyone holding the token can read the payload (
echo <payload> | base64 -d). Sensitive data never goes in it; the signature guarantees integrity, not confidentiality.
The three tokens Keycloak returns:
| Token | For whom | Content | Lifetime at TechCorp | Sent to the API |
|---|---|---|---|---|
| Access token | The resource server (gateway, services) | Authorization claims (aud, scope, roles) |
5 min | Yes, Authorization: Bearer |
| Refresh token | Keycloak only | Opaque to the client | 30 min sliding (session max. 8 h) | Never |
| ID token | The client (SPA/app) | Identity for the UI (name, email) |
5 min | Never |
Short lifetimes are the key decision: a stolen access token is good for five minutes; the client renews it transparently with the refresh token. TechCorp's services do not accept ID tokens (different aud).
- Keycloak for TechCorp: realm, clients, roles and custom claims
Keycloak is deployed in the cluster (official image, its own PostgreSQL, Ingress at auth.techcorp.example; the Platform team operates it). A realm is an isolated space of users, clients and keys; TechCorp uses one: techcorp. Its essential configuration, exportable as JSON and importable at startup (--import-realm):
{
"realm": "techcorp",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800, "ssoSessionMaxLifespan": 28800,
"roles": { "realm": [ { "name": "customer" }, { "name": "operator" }, { "name": "admin" }, { "name": "service" } ] },
"clientScopes": [
{ "name": "orders:create", "protocol": "openid-connect" },
{ "name": "orders:read", "protocol": "openid-connect" },
{ "name": "techcorp-api", "protocol": "openid-connect",
"protocolMappers": [
{ "name": "audience", "protocolMapper": "oidc-audience-mapper",
"config": { "included.custom.audience": "techcorp-api", "access.token.claim": "true" } },
{ "name": "customerId", "protocolMapper": "oidc-usermodel-attribute-mapper",
"config": { "user.attribute": "customerId", "claim.name": "customerId", "access.token.claim": "true", "jsonType.label": "String" } }
] }
],
"clients": [
{ "clientId": "web-store", "publicClient": true, "standardFlowEnabled": true, "directAccessGrantsEnabled": false,
"attributes": { "pkce.code.challenge.method": "S256" },
"redirectUris": ["https://shop.techcorp.example/*"], "webOrigins": ["https://shop.techcorp.example"],
"defaultClientScopes": ["techcorp-api", "orders:create", "orders:read"] },
{ "clientId": "bff-mobile", "publicClient": false, "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "redirectUris": ["techcorp://callback"], "defaultClientScopes": ["techcorp-api", "orders:create", "orders:read"] },
{ "clientId": "orders-service", "publicClient": false, "standardFlowEnabled": false, "serviceAccountsEnabled": true,
"defaultClientScopes": ["techcorp-api", "products:read", "customers:read"] }
]
}What each line decides:
publicClient: true+ PKCES256forweb-store: a SPA cannot keep secrets; PKCE replaces the secret.directAccessGrantsEnabled: falsedisables the password grant.serviceAccountsEnabled: trueandstandardFlowEnabled: falsefororders-service: client credentials only; its service account gets theservicerole (withkcadm.sh add-roles --uusername service-account-orders-service --rolename service).- The
techcorp-apiclient scope addsaud: techcorp-apito every token (Keycloak does not set a usefulaudby default) and thecustomerIdclaim from thecustomerIduser attribute, whichcustomers-servicewrites at sign-up through Keycloak's admin API (Customers is a conformist with respect to Keycloak, 02-03, and storeskeycloak_sub, 02-04). That way no service needs a call to translatesub→customerId. - The realm roles
customer,operator,adminare assigned to people;service, to service accounts. The scopesorders:create,orders:read,products:read,customers:readbound what each client may request, regardless of the user.
The client_secret of orders-service lives in the Kubernetes Secret orders-oidc (OIDC_CLIENT_SECRET), alongside OIDC_ISSUER and OIDC_TOKEN_URL in the ConfigMap; managing and rotating it belongs to 07-04.
- Validation at the gateway: the complete
requireToken with jose
requireToken with joseWe replace the skeleton from 03-04. jose (npm install jose) is the reference library for JWT/JWK in Node.js; createRemoteJWKSet downloads the JWKS, caches it and refreshes it only if an unknown kid arrives (rotation) or cacheMaxAge elapses.
// gateway/auth.js
const { createRemoteJWKSet, jwtVerify, errors } = require('jose');
const ISSUER = process.env.OIDC_ISSUER ?? 'https://auth.techcorp.example/realms/techcorp';
const AUDIENCE = process.env.OIDC_AUDIENCE ?? 'techcorp-api';
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), {
cacheMaxAge: 600_000, // reuse the keys for 10 min without going to Keycloak
cooldownDuration: 30_000 }); // if an unknown kid arrives, at most one download every 30 s (protects Keycloak from an attack with made-up kids)
function problem401(res, req, detail, oauthError = 'invalid_token') { // RFC 6750: WWW-Authenticate says how to authenticate and why it failed
res.set('WWW-Authenticate', `Bearer realm="techcorp", error="${oauthError}", error_description="${detail}"`);
return res.status(401).type('application/problem+json').json({ type: 'about:blank', title: 'Not authenticated', status: 401, code: 'UNAUTHENTICATED', detail, requestId: req.requestId });
}
async function requireToken(req, res, next) {
const auth = req.get('Authorization') ?? '';
if (!auth.startsWith('Bearer ')) return problem401(res, req, 'Missing token', 'invalid_request');
try {
const { payload } = await jwtVerify(auth.slice(7), JWKS, {
issuer: ISSUER, // exact iss: a token from another realm or from a fake Keycloak does not pass
audience: AUDIENCE, // aud must contain techcorp-api: an ID token or a token for another API does not pass
algorithms: ['RS256'], // never accept alg:none or HS256 (with HS256 the "public key" would be enough to sign)
clockTolerance: 30 // 30 s of clock tolerance between Keycloak and the gateway
});
req.user = { sub: payload.sub, customerId: payload.customerId ?? null, roles: payload.realm_access?.roles ?? [],
scopes: (payload.scope ?? '').split(' ').filter(Boolean), azp: payload.azp };
next();
} catch (err) {
if (err instanceof errors.JWTExpired) return problem401(res, req, 'Token expired');
req.log?.warn({ err: err.code }, 'token rejected'); // the real reason goes to the log only
return problem401(res, req, 'Invalid token');
}
}
module.exports = { requireToken };And in gateway/server.js, propagation inward. Before setting our own headers, any that arrive from outside are removed (nobody should be able to send us X-User-Roles: admin from the Internet):
// gateway/server.js (fragments that change with respect to 03-04)
const { requireToken } = require('./auth');
app.use((req, _res, next) => { // 0. Hygiene: internal headers are set only by the gateway
for (const h of Object.keys(req.headers)) if (h.startsWith('x-user-')) delete req.headers[h];
next();
});
// ... requestId, cors, rateLimit, access log as in 03-04 ...
// In proxyTo(), inside on.proxyReq, in addition to X-Request-Id:
// if (req.user) { // only routes that went through requireToken
// proxyReq.setHeader('X-User-Id', req.user.sub);
// proxyReq.setHeader('X-User-Roles', req.user.roles.join(','));
// }
// The Authorization header is forwarded as is: the service verifies the JWT again (section 6)
app.use('/api/v1/products', proxyTo(TARGETS.catalog, { stripPrefix: '/api' })); // public
app.use('/api/v1/orders', requireToken, proxyTo(TARGETS.orders, { stripPrefix: '/api' }));
app.use('/api/v1/customers', requireToken, proxyTo(TARGETS.customers, { stripPrefix: '/api' }));
app.use('/api/graphql', requireToken, proxyTo(TARGETS.bffMobile, { stripPrefix: '/api' }));Warning about
X-User-*. These headers are only trustworthy if nobody but the gateway can reachorders-service:3002. On the cluster network that is, today, an assumption, not a guarantee: a compromised pod in the same namespace could call the service directly with whatever header it likes. That is why (a) the services verify the JWT again (section 6) and use the headers only as a convenience for logs, and (b) 07-02 and 07-04 lock down the network with NetworkPolicies, mTLS and control over who talks to whom.
- Defense in depth:
authenticate() in @techcorp/common-http
authenticate() in @techcorp/common-httpEvery service verifies the token again. It costs microseconds (RSA signature with a cached key) and removes blind trust in the network. The library exposes authenticate(), requireRole() and requireScope(), with the JWKS injectable for the tests in section 10:
// @techcorp/common-http/src/auth.js
const { createRemoteJWKSet, jwtVerify, errors } = require('jose');
const { BusinessError } = require('./errors');
function createRemoteJwks(issuer) {
return createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`), { cacheMaxAge: 600_000, cooldownDuration: 30_000 });
}
// authenticate({ issuer, audience, jwks?, optional? }) → middleware that sets req.user
function authenticate({ issuer, audience, jwks = createRemoteJwks(issuer), optional = false }) {
return async (req, res, next) => {
const auth = req.get('Authorization') ?? '';
if (!auth.startsWith('Bearer ')) {
if (optional) return next(); // public routes: no user, but no error either
res.set('WWW-Authenticate', 'Bearer realm="techcorp", error="invalid_request"');
return next(new BusinessError('UNAUTHENTICATED', 'Missing token', 401));
}
try {
const { payload } = await jwtVerify(auth.slice(7), jwks, { issuer, audience, algorithms: ['RS256'], clockTolerance: 30 });
req.user = { sub: payload.sub, customerId: payload.customerId ?? null, roles: payload.realm_access?.roles ?? [],
scopes: (payload.scope ?? '').split(' ').filter(Boolean), azp: payload.azp };
req.log?.setBindings?.({ userId: payload.sub }); // correlation in the logs (06-01), no personal data
next();
} catch (err) {
const detail = err instanceof errors.JWTExpired ? 'Token expired' : 'Invalid token';
res.set('WWW-Authenticate', `Bearer realm="techcorp", error="invalid_token", error_description="${detail}"`);
next(new BusinessError('UNAUTHENTICATED', detail, 401));
}
};
}
const requireRole = (...roles) => (req, _res, next) =>
roles.some((r) => req.user?.roles.includes(r)) ? next() : next(new BusinessError('FORBIDDEN', `Requires role ${roles.join(' or ')}`, 403));
const requireScope = (scope) => (req, _res, next) =>
req.user?.scopes.includes(scope) ? next() : next(new BusinessError('FORBIDDEN', `Requires scope ${scope}`, 403));
module.exports = { authenticate, requireRole, requireScope, createRemoteJwks };Because they throw BusinessError, the errorMiddleware from 04-02 already turns them into problem+json with code UNAUTHENTICATED or FORBIDDEN. In orders-service, createApp receives the OIDC configuration and mounts the middleware before the business routes and after /health/* (Kubernetes probes carry no token):
// orders-service/src/app.js (fragment)
function createApp({ repository, catalogClient, customersClient, logger, healthChecks = {}, oidc, jwks }) {
// ... requestIdMiddleware, pinoHttp, express.json, createHealthRoutes (no token) ...
app.use('/v1', authenticate({ issuer: oidc.issuer, audience: oidc.audience, jwks })); // everything under /v1/* requires a token
app.use(createOrdersRoutes({ createOrder, repository, requireScope }));
// ... 404 and errorMiddleware ...
}src/config.js (04-03) gains OIDC_ISSUER: z.string().url() and OIDC_AUDIENCE: z.string().default('techcorp-api'); oidc is built from there in server.js.
- Authorization: roles, resource owner and scopes
Authenticated does not mean authorized. TechCorp combines three layers, from coarsest to finest:
| Layer | Question | Mechanism | Example |
|---|---|---|---|
| RBAC (roles) | Does it hold the right role? | requireRole('operator', 'admin') |
List all of today's orders |
| Scopes (per client) | May this OAuth client request this? | requireScope('orders:create') |
A read-only bff-mobile does not create orders |
| Resource (owner) | Is it theirs? | Rule in the use case or the route | Ana only sees her orders |
| ABAC / OPA | Does it satisfy attributes and external policies? | Policy engine (Open Policy Agent, Cedar) | Mention only: for when the rules outgrow what fits in code |
The owner rule in GET /v1/orders/{id} and the 403 or 404 decision:
// orders-service/src/routes/orders.js (GET fragment, extends the one from 04-04)
router.get('/v1/orders/:id', requireScope('orders:read'), async (req, res, next) => {
try {
const order = await repository.get(req.params.id);
if (!order) throw new BusinessError('ORDER_NOT_FOUND', `${req.params.id} does not exist`, 404);
const isOperator = req.user.roles.some((r) => ['operator', 'admin', 'service'].includes(r));
if (!isOperator && order.customerId !== req.user.customerId) {
req.log.warn({ orderId: order.orderId, userId: req.user.sub }, "access to another customer's order"); // signal for 07-03 (audit)
throw new BusinessError('ORDER_NOT_FOUND', `${req.params.id} does not exist`, 404); // policy: 404, not 403
}
// ... ETag and response as in 04-04 ...
} catch (err) { next(err); }
});Why 404 when the order belongs to another customer, if 03-01 said 403? Because a 403 confirms that the identifier exists, and ord-NNNNN ids are easy to enumerate (OWASP calls this BOLA, 07-03): an attacker would learn how many orders there are and which ones are valid. TechCorp's decision: for customers, someone else's order behaves as if it did not exist (404 ORDER_NOT_FOUND); for operators, who do see everything, the case does not arise. 403 FORBIDDEN is reserved for "you are authenticated but your role/scope does not allow this action" (e.g. a customer calling POST /v1/orders/{id}/cancellation on someone else's order: 404 there too, same rule; a customer calling GET /v1/orders?status=PENDING without a customer filter: 403). The OpenAPI contract from 03-01 is updated: the ACCESS_DENIED in its table becomes FORBIDDEN, the only 403 code across the whole platform.
In POST /v1/orders the same idea in reverse: the body carries customerId (03-01), but the token rules. If the user has the customer role, the body's customerId must match req.user.customerId (otherwise 403 FORBIDDEN: there is nothing to hide here); an operator may create orders on behalf of someone else (phone sales). The createOrder use case receives user as part of the request and applies the rule, so the unit test from 04-05 covers it without HTTP.
- Service to service: client credentials and what happens with RabbitMQ
When orders-service calls GET /v1/customers/c-1024 there is no user in front (or there is, but the order may also be created from a job). Orders authenticates as a service with client credentials, and caches the token until shortly before exp. In @techcorp/common-http:
// @techcorp/common-http/src/tokenProvider.js
function createTokenProvider({ tokenUrl, clientId, clientSecret, marginSeconds = 30 }) {
let cache = { token: null, expiresAt: 0 };
let inFlight = null; // avoids 50 simultaneous requests to Keycloak at startup
async function fetchToken() {
const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret });
const res = await fetch(tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(3000) });
if (!res.ok) throw new Error(`Keycloak returned ${res.status} when requesting a token`);
const { access_token, expires_in } = await res.json();
cache = { token: access_token, expiresAt: Date.now() + (expires_in - marginSeconds) * 1000 };
return access_token;
}
return { async get() {
if (cache.token && Date.now() < cache.expiresAt) return cache.token;
inFlight ??= fetchToken().finally(() => { inFlight = null; });
return inFlight;
} };
}
module.exports = { createTokenProvider };createHttpClient (06-03) gains a tokenProvider option: if present, it adds Authorization: Bearer <token> to every request. In the Orders server.js:
const tokenProvider = createTokenProvider({ tokenUrl: config.OIDC_TOKEN_URL, clientId: 'orders-service', clientSecret: config.OIDC_CLIENT_SECRET });
const customersClient = createHttpClient({ baseUrl: config.CUSTOMERS_URL, name: 'customers', tokenProvider });
const catalogClient = createHttpClient({ baseUrl: config.CATALOG_URL, name: 'catalog', tokenProvider });At the receiving end, customers-service accepts GET /v1/customers/{id} if the token has the service role and the customers:read scope, or if it is the customer themselves (customerId in the token = {id}), or an operator. Catalog keeps GET /v1/products with authenticate({ optional: true }): the route is public through the gateway, but if a token arrives it verifies it and could, for instance, return special prices by role.
What about events over RabbitMQ? An order.created message carries no JWT: there is no "request" to authorize, and a 5-minute token makes no sense in a message that may be processed an hour later from a DLQ (06-03). Authentication is of the service to the broker: user orders with the password in the orders-rabbitmq Secret, permissions per vhost, exchange and queue. What each user may publish and consume, and encryption of the amqps:// channel, are detailed in 07-02. If a consumer needs to know who originated the order, that data travels in the event payload (customerId), not as a credential.
- 401 and 403 errors, logout and revocation
TechCorp's closed convention, in RFC 7807 format with code (03-01):
| Situation | Status | code |
Header |
|---|---|---|---|
No token, malformed, invalid signature, wrong iss/aud, expired |
401 |
UNAUTHENTICATED |
WWW-Authenticate: Bearer realm="techcorp", error="invalid_token" (or invalid_request if missing) |
| Valid token, but insufficient role or scope | 403 |
FORBIDDEN |
— |
| Valid token, resource of another customer | 404 |
ORDER_NOT_FOUND (policy from section 7) |
— |
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="techcorp", error="invalid_token", error_description="Token expired"
{"type":"about:blank","title":"Not authenticated","status":401,"code":"UNAUTHENTICATED","detail":"Token expired","requestId":"req-9c1e…"}A 401 with error="invalid_token" is the signal for the SPA to renew with the refresh token and retry; a 403 is not retried.
Logout and revocation. A signed JWT is valid until exp even if the user logs out: the services do not ask Keycloak. TechCorp handles this with short tokens (5 min) plus refresh tokens that Keycloak does revoke (logout via end_session_endpoint, password change, disabled account): after logout, the worst case is five minutes of an already-issued token. For an immediate cut-off (a deprovisioned admin) there are introspection (/token/introspect, one call to Keycloak per request) or a revocation list by jti in Redis, mentioned only: TechCorp does not need them in the first phase.
- Tests: tokens signed with a local key in Jest
The tests from 04-05 must not depend on a Keycloak. Since authenticate() accepts the jwks as a dependency, the tests generate a key pair and sign tokens to order:
// orders-service/test/support/tokens.js
const { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } = require('jose');
const ISSUER = 'https://auth.test/realms/techcorp', AUDIENCE = 'techcorp-api';
let keys;
async function initKeys() { // fresh key pair on every run: nothing to store in the repo
const { publicKey, privateKey } = await generateKeyPair('RS256');
const jwk = { ...(await exportJWK(publicKey)), kid: 'test-1', alg: 'RS256', use: 'sig' };
keys = { privateKey, jwks: createLocalJWKSet({ keys: [jwk] }) };
return { issuer: ISSUER, audience: AUDIENCE, jwks: keys.jwks };
}
async function tokenFor({ sub = 'sub-ana', customerId = 'c-1024', roles = ['customer'], scopes = ['orders:create', 'orders:read'], expiresAt = '5m', aud = AUDIENCE } = {}) {
return new SignJWT({ realm_access: { roles }, scope: scopes.join(' '), customerId, azp: 'web-store' })
.setProtectedHeader({ alg: 'RS256', kid: 'test-1' }).setIssuer(ISSUER).setAudience(aud).setSubject(sub)
.setIssuedAt().setExpirationTime(expiresAt).sign(keys.privateKey);
}
module.exports = { initKeys, tokenFor };// orders-service/test/orders.auth.test.js
const request = require('supertest');
const { createApp } = require('../src/app');
const { initKeys, tokenFor } = require('./support/tokens');
let app;
beforeAll(async () => {
const oidc = await initKeys();
app = createApp({ repository: inMemoryRepository([anaOrder]), catalogClient, customersClient, logger, oidc, jwks: oidc.jwks });
});
const get = (token) => request(app).get('/v1/orders/ord-88213').set('Authorization', `Bearer ${token}`);
test('no token → 401 UNAUTHENTICATED with WWW-Authenticate', async () => {
const res = await request(app).get('/v1/orders/ord-88213');
expect(res.status).toBe(401); expect(res.body.code).toBe('UNAUTHENTICATED'); expect(res.headers['www-authenticate']).toMatch(/Bearer/);
});
test('Ana sees her order; another customer gets 404; expired token → 401', async () => {
expect((await get(await tokenFor())).status).toBe(200);
expect((await get(await tokenFor({ sub: 'sub-other', customerId: 'c-2048' }))).status).toBe(404);
expect((await get(await tokenFor({ expiresAt: '-1m' }))).body.detail).toBe('Token expired');
});
test('without scope orders:create → 403 FORBIDDEN', async () => {
const res = await request(app).post('/v1/orders').set('Authorization', `Bearer ${await tokenFor({ scopes: ['orders:read'] })}`).send(anaOrderBody);
expect(res.status).toBe(403); expect(res.body.code).toBe('FORBIDDEN');
});The Pact contract tests (04-05) stay the same: the provider starts with this local JWKS and the consumer declares the Authorization: Bearer <anything> header with a type matcher; the signature is not part of the contract.
Common Mistakes and Tips
- Accepting any
alg. Withoutalgorithms: ['RS256'], an attacker can sendalg: noneorHS256signed with the public key. Always pin it. - Not checking
aud. An ID token, or an access token issued for another API of the same Keycloak, would pass.audienceis mandatory; that is why thetechcorp-apiclient scope exists. - Downloading the JWKS on every request or caching it forever.
createRemoteJWKSetwithcacheMaxAgeandcooldownDurationis the middle ground: it rotates keys without restarts and does not turn Keycloak into a single point of failure. - Trusting
X-User-*without verifying the JWT in the service. That is the gap 07-02 and 07-04 narrow; until then, double validation is the safety net. - Putting the
client_secretin the ConfigMap, in the image or in a versioned.env. It goes in theorders-oidcSecret (07-04) and never in logs: add*.client_secretandOIDC_CLIENT_SECRETto theredactfrom 06-01 and toconfigForLog()from 04-03. - Using
403for other customers' orders and giving away identifier enumeration. Decide the policy and apply it across the whole platform. - Tip: log
userId(thesub) andazp, neverpreferred_usernameoremail: they identify the person and theredactfrom 06-01 does not cover them. - Tip: the
/health/*probes and/metricscarry no token; mountauthenticate()after them or expose them on another port.
Exercises
Exercise 1. A developer proposes that bff-mobile use the password grant "because the app has its own login screen and that way we don't open the browser". Write three reasons to reject it and the concrete alternative with Keycloak.
Exercise 2. Implement in customers-service the authorization rule for GET /v1/customers/{id} described in section 8: it can be read by the customer themselves, an operator/admin, or a service token with customers:read. Decide what to return when a customer asks for another customer's profile and justify it.
Exercise 3. The orders-service access token expires every 5 minutes and Orders makes ~2 calls to Customers per order (≈6,000/day). How many requests to the token_endpoint does Orders make per day with createTokenProvider (30 s margin) and how many would it make without a cache? What happens if Keycloak is down for 2 minutes with and without the cache?
Solutions
Solution 1. (1) The person's password passes through TechCorp code (the BFF and the app), widening the attack surface: any flaw in them exposes it; with Authorization Code + PKCE only Keycloak sees it. (2) You lose Keycloak's MFA, social login, password policies and brute-force detection, which are applied on its form. (3) It is discouraged by RFC 6819 and removed in OAuth 2.1; Keycloak ships with it disabled (directAccessGrantsEnabled: false) and enabling it is an exception that must be justified in an audit. Alternative: Authorization Code + PKCE with the system browser (ASWebAuthenticationSession/Custom Tabs, or the AppAuth library), redirectUri techcorp://callback, and if a "never leave the app" experience is wanted, customize the Keycloak login theme.
Solution 2.
router.get('/v1/customers/:id', async (req, res, next) => {
try {
const u = req.user, id = req.params.id;
const allowed = u.customerId === id || u.roles.some((r) => ['operator', 'admin'].includes(r)) || (u.roles.includes('service') && u.scopes.includes('customers:read'));
const customer = allowed ? await repository.get(id) : null; // do not touch the DB if not allowed
if (!customer) throw new BusinessError('CUSTOMER_NOT_FOUND', `${id} does not exist`, 404);
res.json(toDto(customer));
} catch (err) { next(err); }
});Same policy as in Orders: for a customer, another customer "does not exist" (404), because c-NNNN ids are enumerable and a 403 would confirm which ones are registered; and the check runs before querying the database so that response time does not give away existence either.
Solution 3. With the cache: a token lasts 300 s and is renewed at 270 s (30 s margin); over 24 h that is 86,400 / 270 ≈ 320 requests to the token_endpoint, regardless of traffic. Without the cache: one per outgoing call, ≈ 6,000 (and ×2 during campaigns), plus Keycloak's latency added to every order. If Keycloak is down for 2 minutes: with the cache, Orders keeps working as long as the current token has not expired (up to 4.5 min in the best case; in the worst, if the outage coincides with the renewal, get() fails and createHttpClient translates it into DEPENDENCY_UNAVAILABLE 503 with Retry-After, like any dependency in 06-03); without the cache, every order fails for those 2 minutes. The cache turns Keycloak into a soft dependency for the synchronous flow; tokens already issued to users also remain valid, because the services verify with the cached JWKS.
Conclusion
TechCorp has gone from "there is an Authorization header" to a verifiable identity across the whole system. Keycloak, in the techcorp realm, authenticates people with Authorization Code + PKCE (web-store, bff-mobile) and services with client credentials (orders-service), issues 5-minute RS256 access tokens with iss, aud: techcorp-api, scope, realm_access.roles and the custom customerId claim, and publishes its keys in the JWKS. The gateway validates with jose (cached createRemoteJWKSet, jwtVerify with issuer, audience and algorithms), strips and propagates X-User-Id/X-User-Roles, and answers 401 UNAUTHENTICATED with WWW-Authenticate; every service verifies again with authenticate() from @techcorp/common-http and authorizes with requireRole, requireScope and the owner rule (someone else's order → 404, insufficient role → 403 FORBIDDEN); Orders obtains its token with createTokenProvider and caches it; RabbitMQ authenticates the service, not the message; and the tests sign tokens with a local key. One assumption remains without a guarantee: that internal requests and the X-User-* headers travel over a network where nobody listens or impersonates. Securing the channel — TLS at the edge, mTLS between services, encrypted RabbitMQ and databases, signed webhooks — is the next lesson: communication security.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
