AWS IAM Identity Center EventBridge 整合:如何自動化離職權限撤銷
Identity Center EventBridge: automate offboarding
AWS IAM Identity Center 會將 Identity Store 的用戶、羣組及成員關係變更送至 EventBridge,供系統更新應用程式權限。
Identity Center EventBridge integration is something many teams have probably been waiting for: as of this week, IAM Identity Center emits an event whenever a user, a group or a group membership is created, updated or deleted in the Identity Store. You can now react to changes instead of polling for them with a script. What interests me most is offboarding, because access that lingers for days after someone switches teams or leaves the company is a classic finding in every security audit.
In this article you will build a small Serverless application that handles exactly that. A Lambda function listens to identity events, checks the current state in the Identity Store and then maintains the access list of your own application. A nightly reconciliation catches whatever slips through the cracks. You describe the infrastructure with the Serverless Framework, and the code is TypeScript.
In short
Identity Center now sends events such as “GroupMembership Deleted” to the EventBridge default bus, in the account and Region of your Identity Center instance. The events only contain IDs, no names or email addresses. The biggest pitfall: delivery is at least once and unordered, so decide based on the current state and reconcile regularly.
What you will learn in this article
- Which events IAM Identity Center sends to EventBridge and what they contain
- Why an event is only a trigger and not the source of truth
- An EventBridge rule with Lambda, retries and a failure queue in the Serverless Framework
- How to revoke access idempotently and reconcile desired vs. actual state every night
- Which account and Region you have to deploy to
What is new with Identity Center and EventBridge?
A quick bit of context: IAM Identity Center is the AWS service for central sign-in (single sign-on) to AWS accounts and applications. Users and groups live in the Identity Store, a directory that you either maintain directly in Identity Center or populate from an external identity provider via SCIM. SCIM is a standard protocol that lets an identity provider push users and groups to other systems automatically.
Until now, reacting to changes meant either polling the Identity Store API on a schedule or digging through CloudTrail records. Since October 28, 2026, the Identity Store delivers its own events to Amazon EventBridge, the AWS event bus. According to the announcement there is no extra cost beyond regular EventBridge charges, and the feature is available in every Region that offers Identity Center. There is nothing to switch on: as soon as a rule with a matching pattern and a target exist, the events start flowing.
| Event (detail-type) | When it is sent |
|---|---|
User Created, User Updated, User Deleted
|
A user is created, changed or deleted |
Group Created, Group Updated, Group Deleted
|
A group is created, changed or deleted |
GroupMembership Created, GroupMembership Deleted
|
A user joins a group or gets removed from it |
IdentityStore Created, …Updated, …Deleted
|
The Identity Store itself changes, for instance when you enable Identity Center |
All events share the source aws.identitystore, and the event name sits in the detail-type field. Here is what an event looks like according to the AWS documentation, slightly shortened:
{
"version": "0",
"id": "c22ba61f-32ac-42f1-a59b-f7f667b428ab",
"detail-type": "User Updated",
"source": "aws.identitystore",
"account": "111122223333",
"time": "2026-01-02T11:59:59Z",
"region": "us-west-2",
"resources": [
"arn:aws:identitystore::111122223333:identitystore/d-1234567890",
"arn:aws:identitystore:::user/a1b2c3d4-5678-90ab-cdef-EXAMPLE11111"
],
"detail": {
"event-version": "0.1",
"request-id": "a1b2c3d4-5678-90ab-cdef-EXAMPLE22222",
"identity-store": { "id": "d-1234567890" },
"user": { "id": "a1b2c3d4-5678-90ab-cdef-EXAMPLE11111" }
}
}
What the event does not tell you
Notice what is missing from Identity Center events on EventBridge: no names, no email addresses, no group names. A User Updated event does not even reveal which attribute changed. From a privacy point of view that makes sense, since no personal data ends up in event archives or logs. For you, however, it means you always fetch details through the Identity Store API, for example with DescribeUser.
On top of that, the documentation lists three properties that shape your design. Events arrive at least once, so duplicates are possible. Ordering is not guaranteed, and neither is a maximum delivery time. AWS therefore explicitly recommends checking the current state through the API and reconciling downstream systems periodically. That is exactly how we will build it.
Which account and Region receive the events
Identity Center events land on the EventBridge default bus in the Region in which you activated Identity Center. With an organization instance, the account is the management account of your AWS Organization; for an account instance, it is the member account in which you turned on Identity Center. If you replicated the instance to more Regions, the same event shows up in each of them. Anyone who creates a rule in every one of those Regions will process each change several times.
The example: a recipe book with its own access list
To make this tangible, picture a fictional bakery with several branches. It runs a small internal web application, the “recipe book”. Sign-in goes through Identity Center, but the recipe book also keeps its own access list in DynamoDB: whoever is on it may view and edit recipes. Secondary lists like this are everywhere in practice, for licenses, for roles inside an application or for accounts in a third-party system.
The rule is simple: anyone in the Identity Center group recipe-book-users belongs on the access list. Anyone who leaves the group or gets deleted is removed, promptly rather than at the next quarterly review.
The event function reacts immediately, while the nightly reconciliation catches missed or delayed events.
Why Identity Center events alone are not enough
The obvious idea would be: add an entry on GroupMembership Created and delete it on GroupMembership Deleted. That works as long as everything arrives in the right order and exactly once. Now imagine an admin accidentally removes a user from the group and adds her back a minute later. If the two events arrive swapped, the deletion wins. According to Identity Center the user is entitled, yet the recipe book locks her out.
That is why I treat the event only as a hint that something changed for this person. The function then calls IsMemberInGroups to check whether the person is in the group right now and brings the access list in line with that state. This approach is idempotent by nature: no matter how often or in what order events arrive, the result always matches the current state in the Identity Store. Idempotent means that running an operation several times gives the same result as running it once.
🎬 How the function reacts to an identity event – this animated diagram only runs on my site: watch it on sascha-ms.de
The second building block is a nightly reconciliation of desired versus actual state. It lists all members of the group, compares them with the access list and fixes any differences. That sounds like double work, but it is your insurance against everything you cannot foresee: a rule that was misconfigured for an hour, a deployment at the wrong moment or an event that simply never arrives. The pattern “events for speed, reconciliation for correctness” has proven itself in event-driven systems, and not only for identities.
Step by step: connecting Identity Center EventBridge events to Lambda
Prerequisites
- An AWS account with IAM Identity Center enabled and at least one test group and one test user
- Access to the account that receives the events (see above), with permissions for CloudFormation, Lambda, DynamoDB, SQS, EventBridge and IAM
- Node.js 22 and AWS CLI v2
- Serverless Framework v4 (
npm i -g serverless); v4 asks you to log in or provide a license key on first use, and it is free for small organizations
A note up front: with an organization instance, events arrive in the management account. You normally do not want to run workloads there. For a first test that is acceptable, but later you should forward the events with a rule to a bus in a dedicated security or tooling account. You will find how to do that further down in the section on common mistakes.
Step 1: find the Identity Store ID and the group ID
In the CLI, switch to the Region of your Identity Center instance. The Identity Store ID starts with d-:
aws sso-admin list-instances --region eu-central-1 \
--query 'Instances[0].IdentityStoreId' --output text
aws identitystore get-group-id --region eu-central-1 \
--identity-store-id d-1234567890 \
--alternate-identifier '{"UniqueAttribute":{"AttributePath":"displayName","AttributeValue":"recipe-book-users"}}'
Expected result: an ID such as d-1234567890 and a response with a GroupId. You need both values for the deployment.
Step 2: create the project
mkdir recipe-book-access && cd recipe-book-access
npm init -y
npm i @aws-sdk/client-identitystore @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm i -D typescript @types/node @types/aws-lambda
mkdir src test
The package.json gets two scripts and the ES module format:
{
"name": "identity-center-offboarding",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types test/*.test.ts"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.1159.0",
"@aws-sdk/client-identitystore": "^3.1159.0",
"@aws-sdk/lib-dynamodb": "^3.1159.0"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.164",
"@types/node": "^22.18.0",
"typescript": "^7.0.2"
}
}
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src", "test"]
}
Serverless Framework v4 handles the build itself with esbuild. You only use TypeScript for type checking, and the tests run directly on Node.js.
Step 3: parse the event
The first file turns the raw event into a small, easily testable object. The documentation only shows the full format for User Updated and states that membership events also carry the user and group IDs. So I read the IDs from detail and otherwise fall back to the ARNs in the resources field. During your first test, log a real membership event and verify the field names before you go to production.
// src/identity-event.ts
import type { EventBridgeEvent } from 'aws-lambda';
export type IdentityDetailType =
| 'User Created' | 'User Updated' | 'User Deleted'
| 'Group Created' | 'Group Updated' | 'Group Deleted'
| 'GroupMembership Created' | 'GroupMembership Deleted'
| 'IdentityStore Created' | 'IdentityStore Updated' | 'IdentityStore Deleted';
export interface IdentityDetail {
'event-version'?: string;
'request-id'?: string;
'identity-store'?: { id?: string };
user?: { id?: string };
group?: { id?: string };
}
export type IdentityEvent = EventBridgeEvent<IdentityDetailType, IdentityDetail>;
export interface ParsedChange {
eventId: string;
type: IdentityDetailType;
identityStoreId?: string;
userId?: string;
groupId?: string;
}
// Extracts an ID from the resources array, e.g. ".../user/<id>"
function idFromResources(resources: string[], kind: 'user' | 'group'): string | undefined {
const match = resources
.map((arn) => new RegExp(`:${kind}/([^/]+)$`).exec(arn))
.find((m) => m !== null);
return match?.[1];
}
export function parseIdentityEvent(event: IdentityEvent): ParsedChange {
if (event.source !== 'aws.identitystore') {
throw new Error(`Unexpected source: ${event.source}`);
}
const detail = event.detail ?? {};
const resources = event.resources ?? [];
return {
eventId: event.id,
type: event['detail-type'],
identityStoreId: detail['identity-store']?.id,
userId: detail.user?.id ?? idFromResources(resources, 'user'),
groupId: detail.group?.id ?? idFromResources(resources, 'group'),
};
}
Step 4: the decision as a pure function
The actual logic lives in a function without any AWS calls. It takes the event type, the IDs and the current membership status and returns one of four actions. If the user ID is missing, it triggers a full reconciliation rather than guessing. The file also contains the diff calculation for the nightly run.
// src/access.ts
export type Action = 'grant' | 'revoke' | 'reconcile' | 'ignore';
// Decides what to do based on the event AND the current state.
// isMember is undefined if the state could not be queried.
export function decideAction(input: {
type: string;
userId?: string;
groupId?: string;
targetGroupId: string;
isMember?: boolean;
}): Action {
const { type, userId, groupId, targetGroupId, isMember } = input;
if (type === 'User Deleted') return userId ? 'revoke' : 'reconcile';
if (type === 'GroupMembership Created' || type === 'GroupMembership Deleted') {
if (!userId) return 'reconcile';
if (groupId && groupId !== targetGroupId) return 'ignore';
if (isMember === undefined) return 'reconcile';
return isMember ? 'grant' : 'revoke';
}
if (type === 'Group Deleted' && groupId === targetGroupId) return 'reconcile';
return 'ignore';
}
// Compares desired state (group members) with actual state (access table).
export function planChanges(members: Iterable<string>, withAccess: Iterable<string>) {
const should = new Set(members);
const has = new Set(withAccess);
return {
grant: [...should].filter((id) => !has.has(id)).sort(),
revoke: [...has].filter((id) => !should.has(id)).sort(),
};
}
Step 5: talk to the Identity Store and DynamoDB
All AWS calls live here. Two details matter. First, grantAccess uses DescribeUser to check that the person still exists and, while at it, loads the user name and email for display in the recipe book. Second, recordOnce writes the audit entry with a condition, so a duplicate event produces only one entry. The expiresAt attribute lets DynamoDB TTL remove audit entries automatically after 90 days.
// src/store.ts
import {
IdentitystoreClient,
IsMemberInGroupsCommand,
DescribeUserCommand,
ResourceNotFoundException,
paginateListGroupMemberships,
} from '@aws-sdk/client-identitystore';
import { DynamoDBClient, ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
PutCommand,
DeleteCommand,
paginateScan,
} from '@aws-sdk/lib-dynamodb';
const ids = new IdentitystoreClient({});
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
function env(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Environment variable ${name} is missing`);
return value;
}
export const config = {
get identityStoreId() { return env('IDENTITY_STORE_ID'); },
get groupId() { return env('TARGET_GROUP_ID'); },
get accessTable() { return env('ACCESS_TABLE'); },
get auditTable() { return env('AUDIT_TABLE'); },
};
export async function isMemberOfTargetGroup(userId: string): Promise<boolean> {
const out = await ids.send(new IsMemberInGroupsCommand({
IdentityStoreId: config.identityStoreId,
MemberId: { UserId: userId },
GroupIds: [config.groupId],
}));
return out.Results?.some((r) => r.GroupId === config.groupId && r.MembershipExists) ?? false;
}
export async function listTargetGroupMembers(): Promise<string[]> {
const members: string[] = [];
const pages = paginateListGroupMemberships(
{ client: ids },
{ IdentityStoreId: config.identityStoreId, GroupId: config.groupId },
);
for await (const page of pages) {
for (const m of page.GroupMemberships ?? []) {
if (m.MemberId && 'UserId' in m.MemberId && m.MemberId.UserId) {
members.push(m.MemberId.UserId);
}
}
}
return members;
}
export async function listUsersWithAccess(): Promise<string[]> {
const users: string[] = [];
const pages = paginateScan(
{ client: ddb },
{ TableName: config.accessTable, ProjectionExpression: 'userId' },
);
for await (const page of pages) {
for (const item of page.Items ?? []) users.push(String(item.userId));
}
return users;
}
export async function grantAccess(userId: string): Promise<boolean> {
try {
const user = await ids.send(new DescribeUserCommand({
IdentityStoreId: config.identityStoreId,
UserId: userId,
}));
await ddb.send(new PutCommand({
TableName: config.accessTable,
Item: {
userId,
userName: user.UserName,
email: user.Emails?.find((e) => e.Primary)?.Value ?? user.Emails?.[0]?.Value,
grantedAt: new Date().toISOString(),
},
}));
return true;
} catch (err) {
// User has been deleted in the meantime: nothing to grant
if (err instanceof ResourceNotFoundException) return false;
throw err;
}
}
export async function revokeAccess(userId: string): Promise<void> {
await ddb.send(new DeleteCommand({ TableName: config.accessTable, Key: { userId } }));
}
// Writes an audit entry exactly once per event ID.
// Returns false if the event was already processed.
export async function recordOnce(entry: {
eventId: string; action: string; userId?: string; type: string;
}): Promise<boolean> {
const ninetyDays = 90 * 24 * 60 * 60;
try {
await ddb.send(new PutCommand({
TableName: config.auditTable,
Item: {
...entry,
processedAt: new Date().toISOString(),
expiresAt: Math.floor(Date.now() / 1000) + ninetyDays,
},
ConditionExpression: 'attribute_not_exists(eventId)',
}));
return true;
} catch (err) {
if (err instanceof ConditionalCheckFailedException) return false;
throw err;
}
}
Step 6: handler and nightly reconciliation
The handler wires the pieces together. It acts first and records afterwards. That order is deliberate: if recording fails, Lambda retries the invocation, and repeating the action does not change the outcome. The other way round, an event could be marked as done while the access still exists.
// src/handler.ts
import { parseIdentityEvent, type IdentityEvent } from './identity-event.ts';
import { decideAction } from './access.ts';
import { reconcileAll } from './reconcile.ts';
import {
config, grantAccess, revokeAccess, isMemberOfTargetGroup, recordOnce,
} from './store.ts';
export const handler = async (event: IdentityEvent): Promise<void> => {
const change = parseIdentityEvent(event);
if (change.identityStoreId && change.identityStoreId !== config.identityStoreId) {
console.warn(JSON.stringify({ msg: 'Foreign identity store', eventId: change.eventId }));
return;
}
const needsState = change.type.startsWith('GroupMembership') && change.userId;
const isMember = needsState ? await isMemberOfTargetGroup(change.userId!) : undefined;
const action = decideAction({
type: change.type,
userId: change.userId,
groupId: change.groupId,
targetGroupId: config.groupId,
isMember,
});
if (action === 'ignore') return;
if (action === 'grant') await grantAccess(change.userId!);
if (action === 'revoke') await revokeAccess(change.userId!);
if (action === 'reconcile') await reconcileAll();
// Act first, then record: a duplicate event does not change the outcome
const firstTime = await recordOnce({
eventId: change.eventId, action, userId: change.userId, type: change.type,
});
console.log(JSON.stringify({
msg: 'Identity event processed', eventId: change.eventId,
type: change.type, action, duplicate: !firstTime,
}));
};
// src/reconcile.ts
import { planChanges } from './access.ts';
import {
grantAccess, revokeAccess, listTargetGroupMembers, listUsersWithAccess,
} from './store.ts';
export async function reconcileAll(): Promise<{ granted: number; revoked: number }> {
const [members, withAccess] = await Promise.all([
listTargetGroupMembers(),
listUsersWithAccess(),
]);
const plan = planChanges(members, withAccess);
let granted = 0;
for (const userId of plan.grant) {
if (await grantAccess(userId)) granted++;
}
for (const userId of plan.revoke) await revokeAccess(userId);
return { granted, revoked: plan.revoke.length };
}
// Invoked nightly by a schedule
export const handler = async (): Promise<void> => {
const result = await reconcileAll();
console.log(JSON.stringify({ msg: 'Reconciliation finished', ...result }));
};
Step 7: the serverless.yml
The rule on the EventBridge default bus listens only for the four Identity Center events that matter to the recipe book. If delivery to Lambda fails, EventBridge retries up to five times within an hour and then puts the event into the failure queue. If, on the other hand, the function itself fails after Lambda’s asynchronous retries, the event lands in the same queue via the Lambda destination onFailure. A CloudWatch alarm fires as soon as anything shows up there. By the way, the specific event names in the pattern are mandatory: CloudTrail forwards Identity Store API calls to EventBridge under the very same source aws.identitystore, using the detail-type AWS API Call via CloudTrail. Without that filter, the function would run on every API call.
# serverless.yml
service: recipe-book-access
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
region: ${opt:region, 'eu-central-1'}
memorySize: 256
timeout: 30
logRetentionInDays: 30
environment:
IDENTITY_STORE_ID: ${param:identityStoreId}
TARGET_GROUP_ID: ${param:targetGroupId}
ACCESS_TABLE: !Ref AccessTable
AUDIT_TABLE: !Ref AuditTable
iam:
role:
statements:
- Effect: Allow
Action:
- identitystore:IsMemberInGroups
- identitystore:DescribeUser
- identitystore:ListGroupMemberships
Resource: '*'
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:DeleteItem
- dynamodb:Scan
Resource: !GetAtt AccessTable.Arn
- Effect: Allow
Action: dynamodb:PutItem
Resource: !GetAtt AuditTable.Arn
- Effect: Allow
Action: sqs:SendMessage
Resource: !GetAtt FailedEventsQueue.Arn
functions:
onIdentityChange:
handler: src/handler.handler
reservedConcurrency: 2
destinations:
onFailure:
type: sqs
arn: !GetAtt FailedEventsQueue.Arn
events:
- eventBridge:
pattern:
source:
- aws.identitystore
detail-type:
- User Deleted
- Group Deleted
- GroupMembership Created
- GroupMembership Deleted
retryPolicy:
maximumEventAge: 3600
maximumRetryAttempts: 5
deadLetterQueueArn: !GetAtt FailedEventsQueue.Arn
nightlyReconcile:
handler: src/reconcile.handler
reservedConcurrency: 1
timeout: 300
events:
- schedule:
rate: cron(15 2 * * ? *)
enabled: true
resources:
Resources:
AccessTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: userId
AttributeType: S
KeySchema:
- AttributeName: userId
KeyType: HASH
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
AuditTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: eventId
AttributeType: S
KeySchema:
- AttributeName: eventId
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expiresAt
Enabled: true
FailedEventsQueue:
Type: AWS::SQS::Queue
Properties:
MessageRetentionPeriod: 1209600
FailedEventsQueuePolicy:
Type: AWS::SQS::QueuePolicy
Properties:
Queues:
- !Ref FailedEventsQueue
PolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: events.amazonaws.com
Action: sqs:SendMessage
Resource: !GetAtt FailedEventsQueue.Arn
FailedEventsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmDescription: Identity events could not be processed
Namespace: AWS/SQS
MetricName: ApproximateNumberOfMessagesVisible
Dimensions:
- Name: QueueName
Value: !GetAtt FailedEventsQueue.QueueName
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
The expression cron(15 2 * * ? *) means every day at 02:15 UTC. If you want to check or adjust the syntax, my EventBridge cron explainer helps. The reservedConcurrency limits the function on purpose: a bulk change via SCIM should not flood the Identity Store with hundreds of parallel requests.
Step 8: test and deploy
Start with the local tests and the type check:
npm test
npm run typecheck
Expected result: eight passing tests and no type errors. Then deploy to the Region of your Identity Center instance and pass both IDs as parameters:
serverless deploy --region eu-central-1 \
--param="identityStoreId=d-1234567890" \
--param="targetGroupId=<GroupId from step 1>"
After the deployment, wait about a minute and then trigger a real event. Get the user ID of your test user and add the user to the group:
USER_ID=$(aws identitystore get-user-id --region eu-central-1 \
--identity-store-id d-1234567890 \
--alternate-identifier '{"UniqueAttribute":{"AttributePath":"userName","AttributeValue":"lena.test"}}' \
--query UserId --output text)
aws identitystore create-group-membership --region eu-central-1 \
--identity-store-id d-1234567890 \
--group-id <GroupId> --member-id UserId=$USER_ID
serverless logs -f onIdentityChange --region eu-central-1 --tail
A line containing "action":"grant" should appear in the log, and the access table should contain an entry for the user ID. Remove the membership again to test revocation:
MEMBERSHIP_ID=$(aws identitystore get-group-membership-id --region eu-central-1 \
--identity-store-id d-1234567890 \
--group-id <GroupId> --member-id UserId=$USER_ID \
--query MembershipId --output text)
aws identitystore delete-group-membership --region eu-central-1 \
--identity-store-id d-1234567890 --membership-id $MEMBERSHIP_ID
Now the log shows "action":"revoke", and the entry in the access table is gone. You can kick off the nightly reconciliation right away with serverless invoke -f nightlyReconcile.
Common mistakes with Identity Center events
The rule sits in the wrong account or Region
This is the most common reason why nothing arrives with Identity Center and EventBridge. The rule has to live on the default bus of the account and Region where the events are created. If you want to run the processing in another account, create a lean rule in the source account whose target is an event bus in the destination account. That bus needs a resource policy that allows events:PutEvents from the source account. For calling the Identity Store API from another account, have a look at delegated administration for Identity Center in the AWS documentation.
Typos in the event name
EventBridge matches exactly, including case and spaces. GroupMembership Deleted works, Group Membership Deleted does not. A handy trick for getting started: a rule with only "source": ["aws.identitystore"] and a log group as target, so you see every real event in its original form. Do not be surprised if CloudTrail entries show up there too, since they carry the same source.
Relying on the order of events
If you translate Created and Deleted directly into write and delete operations, you will end up with wrong states sooner or later. The IsMemberInGroups check costs one API call per event and spares you a whole class of bugs.
Skipping the reconciliation
The documentation is clear about it: no guaranteed delivery time, and events from before the rule existed are never delivered. Without reconciliation, the access list also stays empty after the first deployment until something changes for every single person. So the first nightly run doubles as the initial load.
Overly broad permissions
For simplicity, the serverless.yml grants the Identity Store actions on Resource: '*'. These permissions are read-only anyway, but for production you should still restrict them to the ARNs of your Identity Store and your group. This solution never needs write access to the Identity Store, and it should stay that way.
Cost and security
For the Identity Center events on EventBridge, AWS names no additional cost beyond the usual EventBridge charges. Lambda and on-demand DynamoDB cost next to nothing for a few hundred changes per month, and the SQS queue is normally empty. The largest item is the nightly scan of the access table, and even that stays in the range of cents for a few thousand entries.
On the security side: the function only reads. It does see user names and email addresses, so that data does not belong in logs. The code deliberately logs only IDs and actions. The audit table contains no names either, and TTL cleans it up after 90 days. Adjust the retention period to your own policies.
My take
I find the combination of Identity Center and EventBridge inconspicuous but valuable. Identities are the part of a cloud environment where late reactions hurt the most. Until now, the answer was often “a script runs every night” or “parse CloudTrail and hope you know every API call”. Now there is a clean, domain-level event with a clear meaning.
At the same time, it is tempting to treat the event stream as the truth. AWS itself warns against exactly that, and I think rightly so. Events are fast, but not reliable in the sense of complete and ordered. Combining both, fast reaction via events and regular reconciliation, gives you a system that heals itself. One drawback remains: with organization instances, everything is tied to the management account. I would have liked direct delivery to a delegated account.
If you are interested in how events, failures and retries play together, also check out my articles on Lambda destinations and EventBridge replay.
Technology only covers part of it. What you can do as a leader when someone leaves your team is described in my offboarding checklist for teams.
FAQ
Do I have to enable Identity Center events somewhere?
No. Identity Center sends the events automatically to the default bus of EventBridge. As soon as a rule with the source aws.identitystore and a target exist, they arrive.
Do changes made via SCIM also produce events?
The documentation talks about changes in the Identity Store in general and does not distinguish by channel. I would try it once with a test rule, a log target and your identity provider before relying on it.
How much do Identity Center events cost?
According to the AWS announcement there is no additional cost beyond regular EventBridge charges. On top of that come the costs of your targets, such as Lambda invocations and DynamoDB writes.
Can I get past changes as events after the fact?
No. Events are only delivered from the moment a matching rule exists, and they are not generated retroactively. For the initial state you need a reconciliation through the Identity Store API.
Your next step
Today, create a single rule with "source": ["aws.identitystore"] and a CloudWatch log group as target, then change a test group. Within five minutes you will see real events from your own environment, and the Lambda function from this article can build on that. Which secondary list in your environment would you hook up to identity events first? Let me know in the comments.
Sources
- AWS What’s New: AWS IAM Identity Center’s Identity Store now supports change notifications through Amazon EventBridge (Oct 28, 2026)
- IAM Identity Center User Guide: Event notifications
- IAM Identity Center User Guide: Set up event notifications
- IAM Identity Center User Guide: Receive events in other accounts and Regions
- Identity Store API Reference
- Serverless Framework: EventBridge Event
- Tested with Node.js 22.23, TypeScript 7.0.2, node:test (8 tests), @aws-sdk/client-identitystore, @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb 3.1159.0. The resources in the
serverless.ymlwere checked with cfn-lint, not deployed.
This post was created with AI assistance.
👉 Next step: Need a schedule in EventBridge? My EventBridge cron helper builds and explains the expression for you.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en
來源:DEV Community · dev.to
