Identity Governance Workflows: Building Automated Access Request and Approval with Keycloak
Most identity providers handle authentication well but leave access governance as an afterthought. When a developer needs access to a production database, or a new hire needs specific application roles, the process often involves Slack messages, shared spreadsheets, or manual admin portal clicks. None of that scales, and none of it produces an audit trail worth trusting.
Keycloak does not ship with a built-in access request and approval UI. But it provides all the primitives you need to build one: custom required actions, the Admin REST API, group and role management, and event listeners. This tutorial walks through building an automated access request and approval workflow on top of those primitives.
An identity governance workflow in Keycloak uses the Admin REST API to programmatically assign roles and group memberships based on approval decisions, with admin events providing a tamper-evident audit trail of every access change.
Architecture Overview
The workflow we are building follows a straightforward pattern:
- A user requests access to a resource (a realm role, client role, or group membership)
- The request is persisted and an approver is notified
- An admin reviews and approves or denies the request
- On approval, the Keycloak Admin API assigns the role or group membership
- The decision is logged for audit purposes
The core components are:
- A lightweight request service (your own microservice or serverless function) that stores pending requests and orchestrates the workflow
- Keycloak Admin REST API for all identity mutations (role assignment, group membership)
- Keycloak service account with appropriate permissions to make Admin API calls
- Custom required actions (optional) to prompt users for additional access during login
Setting Up a Service Account for Admin API Access
Before making any Admin API calls, you need a confidential client with a service account. This client acts as the machine identity for your governance workflow.
Create a confidential client in your Keycloak realm with the following configuration. You can use the Keycloak Config Generator to scaffold the initial client setup:
- Client ID:
governance-service - Client Protocol:
openid-connect - Access Type:
confidential - Service Accounts Enabled:
true - Direct Access Grants Enabled:
false
After creating the client, assign the necessary service account roles. Navigate to the client’s Service Account Roles tab and add these realm management roles:
view-usersmanage-usersview-realmquery-groupsquery-users
These roles allow the service account to read user data and modify role/group assignments without granting full admin access.
Obtaining an Access Token
Your governance service authenticates using the client credentials grant:
curl -s -X POST
"https://keycloak.example.com/realms/your-realm/protocol/openid-connect/token"
-H "Content-Type: application/x-www-form-urlencoded"
-d "grant_type=client_credentials"
-d "client_id=governance-service"
-d "client_secret=${CLIENT_SECRET}"
| jq -r '.access_token'
Cache this token for its lifetime (typically 300 seconds) and refresh before expiry. Every subsequent Admin API call includes it as a Bearer token.
Building the Access Request Flow
Defining Requestable Resources
Start by defining which roles and groups are available for self-service requests. A practical approach is to use group attributes or a naming convention to mark groups as requestable:
# Create a group that users can request access to
curl -s -X POST
"https://keycloak.example.com/admin/realms/your-realm/groups"
-H "Authorization: Bearer ${TOKEN}"
-H "Content-Type: application/json"
-d '{
"name": "production-db-access",
"attributes": {
"requestable": ["true"],
"approver-group": ["platform-admins"],
"description": ["Read-only access to production database"]
}
}'
Your governance service can then query for all requestable groups:
# List all groups and filter by attribute
curl -s -X GET
"https://keycloak.example.com/admin/realms/your-realm/groups"
-H "Authorization: Bearer ${TOKEN}"
| jq '[.[] | select(.attributes.requestable[0] == "true")]'
Submitting a Request
When a user requests access, your service persists the request and notifies the appropriate approver. The request payload might look like this:
// POST /api/access-requests
const accessRequest = {
requesterId: "c0ca2f5d-1234-5678-abcd-ef0123456789", // Keycloak user ID
resourceType: "group", // "group", "realm-role", or "client-role"
resourceId: "production-db-access", // group name or role name
justification: "Need read access for Q1 audit queries",
status: "pending",
createdAt: new Date().toISOString()
};
To resolve the approver, look up the approver group defined in the target group’s attributes, then fetch its members:
async function getApprovers(token, realm, groupName) {
// Find the approver group
const groupsResponse = await fetch(
`https://keycloak.example.com/admin/realms/${realm}/groups?search=${groupName}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const groups = await groupsResponse.json();
const approverGroup = groups.find(g => g.name === groupName);
if (!approverGroup) {
throw new Error(`Approver group "${groupName}" not found`);
}
// Fetch group members
const membersResponse = await fetch(
`https://keycloak.example.com/admin/realms/${realm}/groups/${approverGroup.id}/members`,
{ headers: { Authorization: `Bearer ${token}` } }
);
return membersResponse.json();
}
Processing Approvals via the Admin API
When an approver grants a request, your service calls the appropriate Keycloak Admin API endpoint depending on the resource type.
Assigning Group Membership
# Add user to a group
curl -s -X PUT
"https://keycloak.example.com/admin/realms/your-realm/users/${USER_ID}/groups/${GROUP_ID}"
-H "Authorization: Bearer ${TOKEN}"
-H "Content-Type: application/json"
Note the PUT method with no request body. Keycloak returns 204 No Content on success.
Assigning Realm Roles
# First, get the role representation
ROLE=$(curl -s -X GET
"https://keycloak.example.com/admin/realms/your-realm/roles/audit-viewer"
-H "Authorization: Bearer ${TOKEN}")
# Assign the role to the user
curl -s -X POST
"https://keycloak.example.com/admin/realms/your-realm/users/${USER_ID}/role-mappings/realm"
-H "Authorization: Bearer ${TOKEN}"
-H "Content-Type: application/json"
-d "[${ROLE}]"
The role-mappings endpoint expects an array of role representations, not just a role name. You must fetch the full role object first, including its id field.
Assigning Client Roles
Client roles require the client’s internal UUID (not the client ID string):
# Get the client UUID
CLIENT_UUID=$(curl -s -X GET
"https://keycloak.example.com/admin/realms/your-realm/clients?clientId=my-application"
-H "Authorization: Bearer ${TOKEN}"
| jq -r '.[0].id')
# Get the client role representation
ROLE=$(curl -s -X GET
"https://keycloak.example.com/admin/realms/your-realm/clients/${CLIENT_UUID}/roles/editor"
-H "Authorization: Bearer ${TOKEN}")
# Assign the client role to the user
curl -s -X POST
"https://keycloak.example.com/admin/realms/your-realm/users/${USER_ID}/role-mappings/clients/${CLIENT_UUID}"
-H "Authorization: Bearer ${TOKEN}"
-H "Content-Type: application/json"
-d "[${ROLE}]"
A Complete Approval Handler
Here is a JavaScript function that handles the approval for all three resource types:
async function processApproval(token, realm, request) {
const baseUrl = `https://keycloak.example.com/admin/realms/${realm}`;
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
};
switch (request.resourceType) {
case "group": {
const groups = await fetch(
`${baseUrl}/groups?search=${request.resourceId}`,
{ headers }
).then(r => r.json());
const group = groups.find(g => g.name === request.resourceId);
if (!group) throw new Error(`Group not found: ${request.resourceId}`);
const res = await fetch(
`${baseUrl}/users/${request.requesterId}/groups/${group.id}`,
{ method: "PUT", headers }
);
return res.status === 204;
}
case "realm-role": {
const role = await fetch(
`${baseUrl}/roles/${request.resourceId}`,
{ headers }
).then(r => r.json());
const res = await fetch(
`${baseUrl}/users/${request.requesterId}/role-mappings/realm`,
{ method: "POST", headers, body: JSON.stringify([role]) }
);
return res.status === 204;
}
case "client-role": {
const [clientId, roleName] = request.resourceId.split("/");
const clients = await fetch(
`${baseUrl}/clients?clientId=${clientId}`,
{ headers }
).then(r => r.json());
const role = await fetch(
`${baseUrl}/clients/${clients[0].id}/roles/${roleName}`,
{ headers }
).then(r => r.json());
const res = await fetch(
`${baseUrl}/users/${request.requesterId}/role-mappings/clients/${clients[0].id}`,
{ method: "POST", headers, body: JSON.stringify([role]) }
);
return res.status === 204;
}
default:
throw new Error(`Unknown resource type: ${request.resourceType}`);
}
}
Adding Custom Required Actions for Access Requests
Keycloak’s required actions mechanism can prompt users to request access during login. This is useful for onboarding flows where new users need to self-select their team or access level.
A custom required action is a Java SPI that you package as a JAR and deploy to Keycloak’s providers directory. The key interfaces:
public class AccessRequestAction implements RequiredActionProvider {
@Override
public void requiredActionChallenge(RequiredActionContext context) {
// Render a form where the user selects which groups/roles to request
Response challenge = context.form()
.setAttribute("requestableGroups", getRequestableGroups(context))
.createForm("access-request-form.ftl");
context.challenge(challenge);
}
@Override
public void processAction(RequiredActionContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest()
.getDecodedFormParameters();
String selectedGroup = formData.getFirst("requested-group");
// Submit the request to your governance service
submitAccessRequest(context.getUser().getId(), selectedGroup);
context.success();
}
}
Register the required action in the Keycloak admin console under Authentication > Required Actions, then assign it to specific users or set it as a default action for new registrations.
This approach is particularly effective for multi-tenant environments where users need to request access to tenant-specific resources immediately after account creation. For details on configuring required actions alongside MFA, see our guide on configuring MFA in Keycloak.
Implementing Time-Bound Access
Not all access should be permanent. For sensitive resources, implement automatic expiration by scheduling a revocation:
async function grantTemporaryAccess(token, realm, userId, groupId, durationHours) {
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
};
const baseUrl = `https://keycloak.example.com/admin/realms/${realm}`;
// Grant access
await fetch(`${baseUrl}/users/${userId}/groups/${groupId}`, {
method: "PUT",
headers
});
// Schedule revocation
const revokeAt = new Date(Date.now() + durationHours * 3600 * 1000);
// Persist the scheduled revocation in your service's database
await scheduleJob({
action: "revoke-group",
userId,
groupId,
executeAt: revokeAt.toISOString()
});
return { granted: true, expiresAt: revokeAt.toISOString() };
}
async function revokeGroupMembership(token, realm, userId, groupId) {
await fetch(
`https://keycloak.example.com/admin/realms/${realm}/users/${userId}/groups/${groupId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
}
);
}
The DELETE method on the same endpoint removes the group membership. Your job scheduler (cron, Bull, Temporal, or whatever you already run) calls revokeGroupMembership when the timer fires.
Audit Trail with Keycloak Events
Keycloak emits admin events for every mutation made through the Admin API. Enable admin events in your realm settings under Events > Admin Events Settings:
- Save Events: On
- Include Representation: On (stores the request body for each operation)
You can then query the admin events API to build an audit log. Skycloak’s Audit Logs feature provides this visibility out of the box with built-in search and filtering:
# Get recent role assignment events
curl -s -X GET
"https://keycloak.example.com/admin/realms/your-realm/admin-events?
operationTypes=CREATE&resourceTypes=REALM_ROLE_MAPPING,GROUP_MEMBERSHIP&
dateFrom=2026-03-01&max=50"
-H "Authorization: Bearer ${TOKEN}"
| jq '.[] | {time: .time, user: .authDetails.userId, resourcePath: .resourcePath, operation: .operationType}'
For a production governance system, deploy an event listener SPI that forwards these events to your SIEM or audit database in real time, rather than polling the events API. See forwarding Keycloak events to a SIEM via HTTP webhook for a practical example.
Designing the Group Hierarchy for Governance
A well-structured group hierarchy simplifies governance workflows. Consider organizing groups by access pattern:
/environments
/production
/production-read
/production-write
/production-admin
/staging
/staging-read
/staging-write
/teams
/engineering
/data-science
/security
/applications
/billing-service
/billing-viewer
/billing-editor
/analytics-dashboard
/analytics-viewer
Map composite roles to these groups so that group membership automatically grants the correct set of fine-grained permissions. This approach works naturally with Keycloak’s RBAC model. This way, your governance workflow only needs to manage group membership; the role resolution is handled by Keycloak’s role inheritance. For background on Keycloak’s authorization model, see fine-grained authorization in Keycloak explained.
# Assign a composite role to a group
# First, get the group ID
GROUP_ID=$(curl -s -X GET
"https://keycloak.example.com/admin/realms/your-realm/groups?search=production-read"
-H "Authorization: Bearer ${TOKEN}"
| jq -r '.[0].id')
# Get the composite role
ROLE=$(curl -s -X GET
"https://keycloak.example.com/admin/realms/your-realm/roles/prod-readonly"
-H "Authorization: Bearer ${TOKEN}")
# Map the role to the group
curl -s -X POST
"https://keycloak.example.com/admin/realms/your-realm/groups/${GROUP_ID}/role-mappings/realm"
-H "Authorization: Bearer ${TOKEN}"
-H "Content-Type: application/json"
-d "[${ROLE}]"
Operational Considerations
Rate Limiting and Batching
The Keycloak Admin API does not enforce per-endpoint rate limits by default, but your reverse proxy or load balancer might. If you are processing a batch of approvals (e.g., onboarding an entire team), batch the requests and add brief delays between calls to avoid overwhelming the server.
Idempotency
Group membership assignments in Keycloak are idempotent. Calling PUT /users/{id}/groups/{groupId} when the user is already a member returns 204 without error. Role assignments are also idempotent. This simplifies retry logic in your governance service.
Token Lifecycle
Service account tokens have a limited lifespan. If your governance service processes long-running approval queues, refresh the token proactively rather than waiting for a 401 response. A token refresh middleware pattern works well here.
Scaling Governance on Managed Keycloak
Running your own Keycloak cluster means you are responsible for the Admin API’s availability, the event store’s retention, and the database backing it all. If your governance workflows are critical to compliance, any Keycloak downtime directly impacts your ability to process access requests.
A managed Keycloak platform like Skycloak handles the operational burden — high availability, automated backups, and event log retention — so your team can focus on building the governance logic rather than keeping the infrastructure running. This is especially relevant for organizations subject to SOC 2 or ISO 27001, where you need to demonstrate that access reviews and approvals are consistently available and auditable.
Summary
Keycloak’s Admin REST API, group model, and event system provide a solid foundation for building identity governance workflows without bolting on a separate IGA product. The key building blocks are:
- Service accounts with scoped permissions for programmatic access
- Group attributes to define requestable resources and their approvers
- Admin API calls for role and group assignment on approval
- Admin events for a tamper-evident audit trail
- Custom required actions for in-login access request flows
- Scheduled revocation for time-bound access grants
The governance service you build on top of these primitives can be as simple as a single API endpoint with a database table, or as sophisticated as a full workflow engine with multi-level approvals and Slack integration. Keycloak does not prescribe the orchestration layer — it gives you the identity operations and lets you compose them.
Ready to build governance workflows without managing Keycloak infrastructure? Explore Skycloak’s plans and get a managed Keycloak instance running in minutes.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.