Authn & Authz for Microservice: 14 — Request & Response Details (Wire-Level Reference)
Every header, body, and claim that crosses a component boundary in this PoC, in one place.
How to use this doc
This is a lookup reference, not a read-through. Jump to the flow you need:
| You want to know… | Go to |
|---|---|
| What the demo script sends to create a user | Flow 1 |
| How the bootstrap service authenticates to Keycloak | Flow 2 |
| Which Keycloak admin APIs are called | Flow 3 |
| How a user JWT is obtained | Flow 4 |
| What JWT claims exist and why | Flow 5 — Claim Catalog |
| What the client sends to Kong | Flow 6 |
| How Kong introspects the token | Flow 7 |
| What Kong sends to OPA | Flow 8 |
| How Kong forwards to the banking service | Flow 9 |
| What responses the banking service returns | Flow 10 |
| Quick header reference per hop | Flow 11 |
This doc owns the canonical JWT claim catalog (Flows 5A–5K). Docs 06 and 08–09 link here rather than duplicating that material.
End-To-End Data Flow Map
Flow 1: Demo Script -> identity-bootstrap-service
The demo script creates demo users by calling POST http://identity-bootstrap-service:8080/demo/users.
Headers
X-Demo-Bootstrap-Secret: demo-bootstrap-secret
Content-Type: application/json
Request Body — alice
{
"username": "alice",
"password": "Password123!",
"role": "customer",
"customerId": "C-1001",
"accountIds": ["A-1001"]
}
Request Body — ops-admin
{
"username": "ops-admin",
"password": "Password123!",
"role": "ops-admin",
"customerId": "C-9999",
"accountIds": ["A-1001", "A-2001"]
}
Response
On success, identity-bootstrap-service returns HTTP 201:
{
"username": "alice",
"role": "customer",
"status": "created"
}
| Condition | Status |
|---|---|
| Missing bootstrap header | 401 |
| Role not in allowed set | 400 |
| Username exists but not demo-managed | 409 |
Flow 2: identity-bootstrap-service -> Keycloak Token Endpoint
Before creating or updating users, the bootstrap service authenticates to Keycloak as an admin client.
Request
POST http://keycloak:8080/realms/master/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
Form body:
grant_type=password
client_id=admin-cli
username=admin
password=admin
Curl example:
curl -sS -X POST "http://keycloak:8080/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=admin-cli" \
--data-urlencode "username=admin" \
--data-urlencode "password=admin"
Response
Keycloak returns JSON containing at least:
{
"access_token": "<admin-jwt>",
"expires_in": 60,
"refresh_expires_in": 1800,
"refresh_token": "<refresh-token>",
"token_type": "Bearer",
"not-before-policy": 0,
"session_state": "2f914f0e-7690-435a-bf95-d887213fca8e",
"scope": "profile email"
}
The bootstrap service extracts access_token and uses it in all subsequent admin API calls.
Flow 3: identity-bootstrap-service -> Keycloak Admin User APIs
After obtaining an admin token the bootstrap service calls several Keycloak admin endpoints.
Find User By Username
GET /admin/realms/banking-poc/users?username=alice&exact=true
Authorization: Bearer <admin-token>
Response when not found:
[]
Response when found:
[
{
"id": "134d2448-334d-4be5-8868-4d13085bf2cd",
"username": "alice",
"firstName": "alice",
"lastName": "Demo",
"email": "alice@example.local",
"emailVerified": false,
"attributes": {
"customer_id": ["C-1001"],
"account_ids": ["A-1001"]
},
"createdTimestamp": 1780652643152,
"enabled": true,
"totp": false,
"disableableCredentialTypes": [],
"requiredActions": [],
"notBefore": 0,
"access": {
"manageGroupMembership": true,
"view": true,
"mapRoles": true,
"impersonate": true,
"manage": true
}
}
]
Create User
POST /admin/realms/banking-poc/users
Authorization: Bearer <admin-token>
Content-Type: application/json
Body:
{
"username": "alice",
"enabled": true,
"email": "alice@example.local",
"firstName": "alice",
"lastName": "Demo",
"attributes": {
"demo_managed": ["true"],
"customer_id": ["C-1001"],
"account_ids": ["A-1001"]
},
"credentials": [
{
"type": "password",
"value": "Password123!",
"temporary": false
}
]
}
Response: HTTP 201 Created with a Location header pointing to the new user resource.
Update Existing Demo-Managed User
PUT /admin/realms/banking-poc/users/<userId>
Authorization: Bearer <admin-token>
Content-Type: application/json
Body:
{
"username": "alice",
"enabled": true,
"email": "alice@example.local",
"firstName": "alice",
"lastName": "Demo",
"attributes": {
"demo_managed": ["true"],
"customer_id": ["C-1001"],
"account_ids": ["A-1001"]
}
}
Reset Password
PUT /admin/realms/banking-poc/users/<userId>/reset-password
Authorization: Bearer <admin-token>
Content-Type: application/json
Body:
{
"type": "password",
"value": "Password123!",
"temporary": false
}
Read And Sync Realm Roles
Current role lookup:
GET /admin/realms/banking-poc/users/<userId>/role-mappings/realm
Authorization: Bearer <admin-token>
Requested role lookup:
GET /admin/realms/banking-poc/roles/customer
Authorization: Bearer <admin-token>
Remove stale demo-managed role:
DELETE /admin/realms/banking-poc/users/<userId>/role-mappings/realm
Authorization: Bearer <admin-token>
Content-Type: application/json
Add desired role:
POST /admin/realms/banking-poc/users/<userId>/role-mappings/realm
Authorization: Bearer <admin-token>
Content-Type: application/json
Body:
[
{
"id": "role-customer",
"name": "customer",
"composite": false,
"clientRole": false,
"containerId": "banking-poc"
}
]
Flow 4: Demo Script -> Keycloak Login Endpoint
The script gets user tokens directly from Keycloak using the Resource Owner Password Credentials grant.
Request
POST http://localhost:9081/realms/banking-poc/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
Form body:
grant_type=password
client_id=mobile-banking-app
username=alice
password=Password123!
Curl example:
curl -sS -X POST 'http://localhost:9081/realms/banking-poc/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_id=mobile-banking-app' \
--data-urlencode 'username=alice' \
--data-urlencode 'password=Password123!'
Response
{
"access_token": "<signed-jwt>",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "<refresh-token>",
"token_type": "Bearer",
"not-before-policy": 0,
"session_state": "31fd1c66-4930-4538-8f6e-091e9ab9fb0c",
"scope": "email profile"
}
The access_token is the signed JWT. Its payload contains the identity and authorization claims that later components read after validation. The script extracts .access_token.
JWT Decode Example
A JWT has this structure:
header.payload.signature
Decoded header for an alice token:
{
"alg": "RS256",
"typ": "JWT",
"kid": "0BYek66uebuec84BqxfwJ9_qxIDr1Wka-1siBT2z0Lk"
}
The kid tells downstream systems which Keycloak public key to use for verification.
Decoded payload:
{
"exp": 1781580804,
"iat": 1781580504,
"jti": "c2c1b237-1501-4602-a42f-f3b7a5a69590",
"iss": "http://keycloak:8080/realms/banking-poc",
"aud": "mobile-banking-app",
"sub": "134d2448-334d-4be5-8868-4d13085bf2cd",
"typ": "Bearer",
"azp": "mobile-banking-app",
"sid": "31fd1c66-4930-4538-8f6e-091e9ab9fb0c",
"acr": "1",
"realm_access": {
"roles": ["customer"]
},
"scope": "email profile",
"account_ids": ["A-1001"],
"email_verified": false,
"name": "alice Demo",
"preferred_username": "alice",
"given_name": "alice",
"customer_id": "C-1001",
"family_name": "Demo",
"email": "alice@example.local"
}
For the full claim-by-claim breakdown, see Flow 5E.
Signature
The third JWT segment is the cryptographic signature. Keycloak generates it by signing the header and payload with its private key. Downstream systems verify it with Keycloak’s public key (fetched via JWKS).
- If the header or payload changes after issuance, the signature no longer matches.
- If the token was not issued by Keycloak, the signature cannot be verified.
- Decoding lets you inspect claims. Signature verification or introspection is what lets you trust them.
Flow 5: JWT Claims Produced By Keycloak
This section is the canonical claim catalog for this PoC. Docs 06, 08, and 09 link here rather than repeating this material.
Flow 5A: What JWT Claims Are
JWT claims are named key-value facts stored inside the token payload. Each field in the JSON object below is a claim:
{
"iss": "http://keycloak:8080/realms/banking-poc",
"aud": "mobile-banking-app",
"preferred_username": "alice",
"customer_id": "C-1001",
"account_ids": ["A-1001"]
}
Standard claims are defined by JWT/OIDC conventions (iss, aud, exp, sub, iat). They solve common token-validation problems.
Custom claims are application-specific additions. In this PoC: customer_id and account_ids. They solve banking-domain authorization problems.
Flow 5B: Why We Need JWT Claims
After a user logs in, Kong (PEP), OPA (PDP), and banking-api-service each need to know:
- who is calling
- whether the token came from the right issuer
- what role the user has
- which customer/account scope belongs to the user
Claims carry that context with the request so each component does not have to query another system for every call.
Flow 5C: What Problem JWT Claims Solve
| Problem | Claims used |
|---|---|
| Identity propagation | preferred_username, sub |
| Token validation context | iss, aud, exp |
| Authorization context | realm_access.roles, customer_id, account_ids |
| Reducing repeated lookups | all of the above carried in-token |
Flow 5D: What JWT Claims Do Not Solve
Claims are not sufficient on their own.
- A decoded claim is not automatically trustworthy — anyone can craft a fake JWT string.
- A valid token can still be unauthorized for a specific action.
- If the source of truth changes, old tokens still contain the claim values they had at issuance until they expire.
That is why this PoC still uses Keycloak introspection in Kong, JWT validation in banking-api-service, OPA policy evaluation, and service-side authorization checks.
Flow 5E: Which Claims This PoC Uses And Why
iss
- Meaning: which Keycloak realm issued the token (
http://keycloak:8080/realms/banking-poc) - Used by:
banking-api-service - Purpose: reject tokens not issued by this realm
aud
- Meaning: which client/application the token is meant for (
mobile-banking-app) - Used by:
banking-api-service - Purpose: reject tokens not intended for this application
preferred_username
- Meaning: human-readable username (
aliceorops-admin) - Used by: Kong (logging, OPA input), diagnostics
- Purpose: identity context; also forwarded as
input.usernameto OPA
realm_access.roles
- Meaning: realm roles assigned to the user (
["customer"]or["ops-admin"]) - Used by: Kong, OPA,
banking-api-service - Purpose: distinguish
customerfromops-admin; Kong extracts the first role and sends it asinput.roleto OPA
customer_id
- Meaning: business identifier for the banking customer (
C-1001foralice,C-9999forops-admin) - Used by: OPA (
input.customer_id),banking-api-service - Purpose: connect authenticated identity to banking ownership; OPA checks
customer_id != ""for thecustomerrole
account_ids
- Meaning: accounts the token carries (
["A-1001"]foralice,["A-1001","A-2001"]forops-admin) - Used by: OPA (
input.account_ids),banking-api-service - Purpose: account-level authorization; OPA checks that the requested
account_idis inaccount_ids
sub
- Meaning: Keycloak internal user UUID
- Used by:
banking-api-service(Spring Security principal) - Purpose: stable unique identifier for the subject
exp / iat / jti
- Meaning: expiry time, issued-at time, JWT ID
- Used by: token validation in Kong (via introspection) and Spring Security
- Purpose: prevent token reuse after expiry; provide audit trail
Flow 5F: JWT Claims In This PoC At A Glance
Claims originate in Keycloak. Kong and banking-api-service read them — they do not invent them.
Flow 5G: Where The Claims Come From
Step 1: User Attributes Written Into Keycloak
When identity-bootstrap-service creates or updates a user it sends:
{
"attributes": {
"demo_managed": ["true"],
"customer_id": ["C-1001"],
"account_ids": ["A-1001"]
}
}
That comes from KeycloakAdminProvisioner:
private Map<String, List<String>> attributes(DemoUserRequest request) {
return Map.of(
DEMO_MANAGED_ATTRIBUTE, List.of(DEMO_MANAGED_VALUE),
"customer_id", List.of(request.customerId()),
"account_ids", request.accountIds());
}
For alice: customer_id = C-1001, account_ids = [A-1001].
For ops-admin: customer_id = C-9999, account_ids = [A-1001, A-2001].
Step 2: Realm Roles Stored In Keycloak
The bootstrap service assigns a realm role (customer or ops-admin). Keycloak automatically places realm roles into the token under realm_access.roles. Both roles are declared in infra/keycloak/realm-export.json.
Step 3: Protocol Mappers Copy Attributes Into The Token
The mobile-banking-app client in infra/keycloak/realm-export.json has three protocol mappers. These mappers are the bridge between stored user attributes and JWT claims.
customer_id mapper — reads the customer_id user attribute and places it into the access token claim customer_id as a String:
{
"name": "customer_id",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"access.token.claim": "true",
"claim.name": "customer_id",
"user.attribute": "customer_id",
"jsonType.label": "String"
}
}
account_ids mapper — reads the account_ids user attribute and places it into the access token claim account_ids as a multivalued String array:
{
"name": "account_ids",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"access.token.claim": "true",
"claim.name": "account_ids",
"user.attribute": "account_ids",
"jsonType.label": "String",
"multivalued": "true"
}
}
mobile-banking-app-audience mapper — adds mobile-banking-app to the token audience:
{
"name": "mobile-banking-app-audience",
"protocolMapper": "oidc-audience-mapper",
"config": {
"access.token.claim": "true",
"included.client.audience": "mobile-banking-app"
}
}
That is why banking-api-service can check aud = mobile-banking-app.
Flow 5H: How The JWT Is Structured
header.payload.signature
Each part is Base64URL-encoded.
Header — signing algorithm, key ID, token type:
{
"alg": "RS256",
"typ": "JWT",
"kid": "..."
}
Payload — the claims. This is what Kong decodes after introspection and what Spring Security exposes via the Jwt principal:
{
"iss": "http://keycloak:8080/realms/banking-poc",
"aud": "mobile-banking-app",
"preferred_username": "alice",
"realm_access": {
"roles": ["customer"]
},
"customer_id": "C-1001",
"account_ids": ["A-1001"]
}
Signature — cryptographic proof created by Keycloak with its private key. Decoding the payload is easy. Trusting it requires validation.
Flow 5I: Why Kong Can Decode Claims But Still Must Validate
The Kong plugin decodes the JWT payload segment locally to read claims:
local function decode_claims(token)
local payload_segment = token:match("^[^.]+%.([^.]+)%.([^.]+)$")
...
return cjson.decode(decoded)
end
But payload decoding alone is unsafe — anyone can craft a string that looks like a JWT. So Kong first calls Keycloak introspection:
- Kong receives the bearer token.
- Kong calls the Keycloak introspection endpoint.
- Keycloak responds with
active: trueoractive: false. - Only then does Kong use the decoded payload to build OPA input.
That is the difference between reading claims and trusting claims.
Flow 5J: How banking-api-service Retrieves The Same Claims
banking-api-service does not do manual Base64 decoding. Instead:
- Spring Security validates the JWT signature and checks
issandaud. - It creates a validated
Jwtprincipal object. - Controller and guard code read claims from that object:
jwt.getClaimAsString("customer_id")jwt.getClaimAsStringList("account_ids")realm_access.roles(via a custom converter)
The same claim values therefore flow through two separate enforcement paths:
- Kong → OPA path
banking-api-serviceservice-side defense-in-depth path
Flow 5K: End-To-End Claim Pipeline For alice
Concrete example — alice
User attributes stored in Keycloak:
{
"customer_id": ["C-1001"],
"account_ids": ["A-1001"]
}
Role assigned in Keycloak:
{
"realm_access": {
"roles": ["customer"]
}
}
Audience added by mapper:
{
"aud": "mobile-banking-app"
}
Final representative payload seen by the rest of the system:
{
"iss": "http://keycloak:8080/realms/banking-poc",
"aud": "mobile-banking-app",
"preferred_username": "alice",
"realm_access": {
"roles": ["customer"]
},
"customer_id": "C-1001",
"account_ids": ["A-1001"]
}
Why each claim matters:
| Claim | Consumer | Purpose |
|---|---|---|
iss |
banking-api-service |
Checks the token came from this Keycloak realm |
aud |
banking-api-service |
Checks the token is intended for mobile-banking-app |
realm_access.roles |
Kong, OPA, banking-api-service |
Distinguishes customer from ops-admin |
customer_id |
OPA, banking-api-service |
Links identity to banking ownership |
account_ids |
OPA, banking-api-service |
Account-level authorization |
Flow 6: Client -> Kong
The client calls Kong at http://localhost:8000/api/accounts/.... Kong routes all /api/accounts traffic via the banking-api-route route defined in infra/kong/kong.yml.
Example Request
GET /api/accounts/A-1001 HTTP/1.1
Host: localhost:8000
Authorization: Bearer <jwt>
| Condition | Response |
|---|---|
| Missing token | 401 {"message":"missing bearer token"} |
| Malformed token | 401 {"message":"invalid bearer token"} |
Flow 7: Kong -> Keycloak Introspection
Before using token claims for OPA input, Kong introspects the token. The opa-authz plugin in infra/kong/kong.yml is configured with introspection_url, introspection_client_id (kong-introspection), and introspection_client_secret (kong-introspection-secret).
Request
POST http://keycloak:8080/realms/banking-poc/protocol/openid-connect/token/introspect
Authorization: Basic base64(kong-introspection:kong-introspection-secret)
Content-Type: application/x-www-form-urlencoded
token=<jwt>
Response — Valid Token
{
"active": true,
"client_id": "mobile-banking-app",
"username": "alice",
"token_type": "Bearer",
"exp": 1781236914,
"iat": 1781236614,
"sub": "134d2448-334d-4be5-8868-4d13085bf2cd",
"aud": "mobile-banking-app",
"iss": "http://keycloak:8080/realms/banking-poc"
}
Response — Tampered Or Inactive Token
{
"active": false
}
Kong then returns:
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
{"message":"inactive token"}
Flow 8: Kong -> OPA
If introspection returns active: true, Kong decodes the JWT claims locally and sends them to OPA.
The OPA endpoint is http://opa:8181/v1/data/banking_authz/allow (from infra/kong/kong.yml). The policy lives in infra/opa/policies/banking_authz.rego.
Request
POST http://opa:8181/v1/data/banking_authz/allow
Content-Type: application/json
Body for alice accessing A-1001:
{
"input": {
"method": "GET",
"path": "/api/accounts/A-1001",
"account_id": "A-1001",
"customer_id": "C-1001",
"account_ids": ["A-1001"],
"role": "customer",
"username": "alice"
}
}
Input field mapping — all fields come from the JWT claims Kong decoded after introspection:
| OPA input field | Source claim | Notes |
|---|---|---|
method |
HTTP method | From the incoming request |
path |
HTTP path | From the incoming request |
account_id |
path segment | Extracted from /api/accounts/{id} |
customer_id |
customer_id |
JWT custom claim |
account_ids |
account_ids |
JWT custom claim array |
role |
realm_access.roles[0] |
First realm role |
username |
preferred_username |
JWT standard claim |
OPA Policy Logic (from banking_authz.rego)
allow {
read_only_account_request
input.role == "ops-admin"
}
allow {
read_only_account_request
input.role == "customer"
input.customer_id != ""
account_ids := object.get(input, "account_ids", [])
account_ids[_] == input.account_id
}
read_only_account_request {
input.method == "GET"
regex.match("^/api/accounts/[^/]+(?:/transactions)?$", input.path)
}
ops-admin gets read access to any account. customer must have a non-empty customer_id and the requested account_id must appear in their account_ids.
Response — Allowed
{
"result": true
}
Response — Denied
{
"result": false
}
Kong returns:
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{"message":"forbidden"}
Flow 9: Kong -> banking-api-service
If OPA returns result: true, Kong forwards the request to http://banking-api-service:8080 (configured as the banking-api service in infra/kong/kong.yml).
Forwarded Request
GET /api/accounts/A-1001 HTTP/1.1
Authorization: Bearer <jwt>
Kong passes the original bearer token unchanged. banking-api-service is the resource server and treats this as its own authentication boundary.
Spring Security Validation Flow
Spring Security is configured via application.yml:
spring.security.oauth2.resourceserver.jwt.jwk-set-uri— where to fetch Keycloak’s public keysbanking-api.security.issuer-uri— which Keycloak realm must have issued the tokenbanking-api.security.audience— which client the token must be intended for (mobile-banking-app)
Validation steps:
- Extract the bearer token from the
Authorizationheader. NimbusJwtDecoderloads JWKs from the JWKS endpoint.- Verify the JWT signature against Keycloak’s public key.
- Check that
issmatches the configured realm URI. - Check that
audcontainsmobile-banking-app. - If any check fails, Spring Security returns
401 Unauthorizedbefore the controller is called.
Service-Side Authorization Guard
Once the token is accepted, AccountAccessGuard applies its own rules before returning data:
| Role | Rule |
|---|---|
ops-admin |
Access any account |
customer |
Must have non-empty customer_id; requested accountId must appear in account_ids |
The service reads these claims from the validated Jwt principal:
realm_access.rolescustomer_idaccount_ids
If the token is missing or claims do not match, the guard throws 401 or 403 accordingly.
Why validate again after Kong already checked?
- Kong is a gateway control point, not the banking service’s trust boundary.
- The service must defend itself if called directly, bypassing Kong, or if the gateway is misconfigured.
- Kong answers “should this request enter the platform?” — the service answers “should I trust and act on this token?”
- The service needs the validated
Jwtobject so guard code can make account-specific decisions.
This is defense in depth: Kong filters bad traffic early; the service still enforces its own rules before returning banking data.
For the signature and trust model, see 11 — JWT Signature Validation and 12 — JWKS.
Flow 10: banking-api-service Responses
Account Details Success
GET /api/accounts/A-1001
{
"accountId": "A-1001",
"customerId": "C-1001",
"currency": "GBP"
}
Transactions Success
GET /api/accounts/A-1001/transactions
[
{
"accountId": "A-1001",
"amount": -12.35
}
]
Error Responses
| Condition | Status |
|---|---|
| Account not found (after auth passes) | 404 Not Found |
| Valid token but claims forbid access (direct/internal call) | 403 Forbidden |
| Token fails JWT validation | 401 Unauthorized |
Flow 11: Which Component Sends Which Important Header
| Hop | Key headers |
|---|---|
Demo Script → identity-bootstrap-service |
X-Demo-Bootstrap-Secret, Content-Type: application/json |
identity-bootstrap-service → Keycloak token endpoint |
Content-Type: application/x-www-form-urlencoded |
identity-bootstrap-service → Keycloak admin APIs |
Authorization: Bearer <admin-token>, Content-Type: application/json (writes) |
| Client → Kong | Authorization: Bearer <jwt> |
| Kong → Keycloak introspection | Authorization: Basic <base64(clientId:clientSecret)>, Content-Type: application/x-www-form-urlencoded |
| Kong → OPA | Content-Type: application/json |
Kong → banking-api-service |
forwarded Authorization: Bearer <jwt> |
Quick Comparison Table
| Sender | Receiver | Main purpose | Body style |
|---|---|---|---|
| Demo script | identity-bootstrap-service |
Create demo user | JSON |
identity-bootstrap-service |
Keycloak token endpoint | Get admin token | Form URL-encoded |
identity-bootstrap-service |
Keycloak admin API | Create/update users and roles | JSON |
| Demo script | Keycloak token endpoint | Get user JWT | Form URL-encoded |
| Client | Kong | Call protected banking API | Usually no body for GET |
| Kong | Keycloak introspection | Validate token activity | Form URL-encoded |
| Kong | OPA | Ask policy decision | JSON |
| Kong | banking-api-service |
Forward allowed request | Forwarded original request |
← Prev: 13 — Access & Refresh Token Lifecycle
📚 返回专栏目录
更多推荐



所有评论(0)