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.

Architecture: Identity Center sends events via EventBridge to Lambda, which maintains DynamoDB access; a second Lambda reconciles nightly

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: Source:https://dev.to/sascha-ms/identity-center-eventbridge-automate-offboarding-4jed

Related posts