Production-grade guide to identity access management patterns covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.
Identity Access Management (IAM) patterns are reusable solutions for managing digital identities and controlling access to resources across systems, services, and environments. They are essential when an organisation has multiple applications, users, teams, and external partners, and must enforce consistent policies across systems that may use different protocols, credentials, and scopes. IAM patterns solve the recurring problem of *how to authenticate users, authorise their actions, and manage their lifecycles*—not just in one app, but across the entire stack.
User provisioning is the process of creating, updating, and deactivating identities in downstream systems. It begins when a user is added to a central identity store—typically a directory service like LDAP or a cloud identity provider—and propagates to all integrated applications.
A user record is created in the identity provider (IdP) with attributes such as user_id, email, department, manager, and status. When a new user is added, an event is emitted: { "event": "user.created", "user_id": "u-1234", "email": "[email protected]", "department": "engineering" }.
This event triggers a workflow that synchronises the user to external systems. For example, a user_provisioner service consumes this event and performs the following steps:
curl -X POST https://api.idp.acme.com/events \
-H "Content-Type: application/json" \
-d '{
"event": "user.created",
"user_id": "u-1234",
"email": "[email protected]",
"department": "engineering",
"timestamp": "2024-03-15T12:00:00Z"
}'
The user_provisioner service reads a configuration file provisioning.yaml:
services:
- name: "auth-service"
endpoint: "https://auth.acme.com/v1/users"
auth:
type: "bearer"
token: "sso:auth:prod"
mapping:
user_id: "user_id"
email: "email"
department: "department"
status: "status"
conditions:
- "department == 'engineering'"
- "status == 'active'"
On receiving the event, the service makes a POST request to the target API:
POST /v1/users HTTP/1.1
Host: auth.acme.com
Authorization: Bearer sso:auth:prod
Content-Type: application/json
{
"user_id": "u-1234",
"email": "[email protected]",
"department": "engineering",
"status": "active"
}
The service handles common failure modes:
user_id is provisioned twice, the API must accept duplicate POST requests without creating duplicate users. This is achieved via ETag or If-None-Match headers.event_id or user_id as a primary key.auth-service returns 503), the workflow continues with fallbacks: retry with exponential backoff, and log the failure to a provisioning_errors table.A common silent failure is stale user data: a user’s department is updated in the IdP, but the user_provisioner runs only once daily. The department field in the auth-service remains outdated until the next sync. To fix this, the service must also listen for user.updated events.
Attributes in the IdP may not match those in downstream systems. The mapping between user.department in the IdP and user.dept in the auth-service must be explicit. A mismatched field (e.g., user.location → user.region) silently breaks role assignment.
The service must validate that all required fields are present. If email is missing, it logs:
{
"event_id": "evt-9876",
"service": "auth-service",
"error": "missing_required_field",
"field": "email",
"timestamp": "2024-03-15T12:01:30Z",
"payload": {
"user_id": "u-1234",
"department": "engineering"
}
}
When a user is deactivated in the IdP, the user_provisioner updates the user’s status to inactive, and triggers a user.deactivated event. This event is consumed by a user_suspension_service that:
user_archive tableWhen a user accesses a service that requires additional permissions, the IdP presents a consent screen. The flow is initiated via an Authorization Code grant.
The client application sends a request:
GET /oauth/authorize?
client_id=app-xyz
&redirect_uri=https://app.acme.com/callback
&response_type=code
&scope=read:files write:files profile
&state=abc123
&prompt=consent
HTTP/1.1
Host: idp.acme.com
The IdP responds with a form that lists requested scopes and asks the user to approve:
> You’re signing in to App XYZ. Grant access to: > > - Read your files > - Write to your files > - View your profile > > [ ] Remember this decision for 30 days > [Submit]
After submission, the IdP returns a code via redirect_uri:
GET /callback?code=auth-code-456&state=abc123 HTTP/1.1
Host: app.acme.com
The client exchanges the code for an access token:
POST /oauth/token HTTP/1.1
Host: idp.acme.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=auth-code-456
&redirect_uri=https://app.acme.com/callback
&client_id=app-xyz
&client_secret=secret-abc
The response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx.xxxxx",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt-789",
"scope": "read:files write:files profile",
"issued_at": 1710585600
}
The client uses this token to call the API:
GET /api/v1/files HTTP/1.1
Host: app.acme.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx.xxxxx
The IdP must track which users granted which scopes. This is stored in a user_scopes table:
| user_id | client_id | scopes | granted_at | expires_at | remember |
|---|---|---|---|---|---|
| u-1234 | app-xyz | read:files,write:files | 2024-03-15 | 2024-06-15 | true |
When a user logs in again, the IdP checks if they previously granted consent for app-xyz. If remember = true, it skips the consent screen.
A common failure is scope drift: the client adds a new scope read:reports, but the IdP does not update its list of supported scopes. Users get 400 Bad Request when requesting read:reports, but the scope field in the token is read:files write:files profile, missing the new one.
To handle this, the client must dynamically discover the supported scopes via the .well-known/openid-configuration endpoint:
{
"authorization_endpoint": "https://idp.acme.com/oauth/authorize",
"token_endpoint": "https://idp.acme.com/oauth/token",
"scopes_supported": [
"openid",
"profile",
"email",
"read:files",
"write:files",
"read:reports"
],
"claims_supported": [
"sub",
"email",
"name",
"department"
]
}
The client checks scopes_supported and includes only valid scopes in the scope parameter. If a scope is invalid, the IdP returns:
{
"error": "invalid_scope",
"error_description": "The requested scope 'read:reports' is not supported by this authorization server.",
"supported_scopes": [
"openid",
"profile",
"email",
"read:files",
"write:files"
]
}
When a user accesses a service, they may be redirected to the IdP for authentication. The IdP uses SAML 2.0 to exchange identity and authentication information.
The service sends an AuthnRequest as a POST to the IdP:
<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="req-1234"
Version="2.0"
IssueInstant="2024-03-15T12:00:00Z"
Destination="https://idp.acme.com/saml/SSO"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://app.acme.com</saml:Issuer>
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" AllowCreate="true" />
</samlp:AuthnRequest>
The IdP authenticates the user and returns a SAMLResponse:
<samlp:Response
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="resp-5678"
Version="2.0"
IssueInstant="2024-03-15T12:00:30Z"
Destination="https://app.acme.com/saml/acs"
InResponseTo="req-1234">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://idp.acme.com</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
<saml:Assertion
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="assertion-9012"
IssueInstant="2024-03-15T12:00:30Z"
Version="2.0">
<saml:Issuer>https://idp.acme.com</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">u-1234</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData
InResponseTo="req-1234"
NotOnOrAfter="2024-03-15T12:05:30Z"
Recipient="https://app.acme.com/saml/acs" />
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2024-03-15T12:00:30Z" NotOnOrAfter="2024-03-15T12:05:30Z">
<saml:AudienceRestriction>
<saml:Audience>https://app.acme.com</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AttributeStatement>
<saml:Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml:AttributeValue>[email protected]</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="department" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml:AttributeValue>engineering</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
The service validates the response:
InResponseTo matches the original AuthnRequestAudience matches the service’s EntityIDNotOnOrAfter to prevent replay attacksNameID and SubjectConfirmationDataA common silent failure is clock skew: if the IdP and service have different clocks, a NotOnOrAfter time may be invalid. The service must allow a buffer (e.g., 5 minutes) and log clock_skew_detected events.
Roles are assigned to users via user_roles table:
| user_id | role_id | effective_from | effective_to | granted_by |
|---|---|---|---|---|
| u-1234 | r-engineer | 2024-01-01 | 2025-01-01 | [email protected] |
Each role has associated permissions:
{
"role_id": "r-engineer",
"name": "Engineer",
"permissions": [
"files:read",
"files:write",
"projects:edit",
"users:manage"
],
"scopes": [
"read:files",
"write:files"
],
"description": "Full access to code and project systems"
}
An API endpoint /auth/permissions returns a user’s effective permissions:
GET /auth/permissions?user_id=u-1234 HTTP/1.1
Host: auth.acme.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx.xxxxx
Response:
{
"user_id": "u-1234",
"effective_permissions": [
"files:read",
"files:write",
"projects:edit",
"users:manage"
],
"granted_by": [
"role:r-engineer",
"assignment:u-1234"
],
"effective_from": "2024-01-01",
"effective_to": "2025-01-01"
}
A key failure mode is role inheritance: a r-lead role includes r-engineer, but the auth-service does not recursively apply permissions. The service must query the role_hierarchy table and merge permissions.
When a user’s role changes, the auth-service must:
user.role.changed eventWebSocket or Redis broadcastAccess tokens are short-lived (e.g., 1 hour). When a client receives a token, it must monitor its expiry and refresh it.
The auth-service returns a token with expires_in and refresh_token:
{
"access_token": "a-1234",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt-5678"
}
The client stores the token and checks its expiry every 5 minutes. When expires_in < 300 seconds, it initiates a refresh:
POST /oauth/token HTTP/1.1
Host: idp.acme.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=rt-5678
&client_id=app-xyz
&client_secret=secret-abc
The IdP returns:
{
"access_token": "a-9999",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt-1111",
"scope": "read:files write:files profile"
}
A common failure is token invalidation: when a user logs out from one service, the IdP must invalidate the access_token and refresh_token. The service must check Authorization: Bearer <token> against a token_revocation table and return 401 Unauthorized if the token is invalid.
The IdP must also handle token rotation: when a refresh token is used, it generates a new refresh token and invalidates the old one. This prevents replay and enables seamless session continuation.
When a client fails to refresh in time, the user sees a “token expired” error:
{
"error": "token_expired",
"error_description": "The access token is no longer valid.",
"error_uri": "https://docs.acme.com/errors/token-expired"
}
The client must retry the request with a new token, or redirect to the IdP for re-authentication.
This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy