How I Made Microsoft Entra ID Groups Behave Like Cognito Groups
A small Terraform setup that federates Entra ID into Cognito and rewrites cognito:groups via a pre-token-generation Lambda, so your apps can authorise on real Entra group IDs.
Every now and then a piece of integration work shows up that looks simple from a one-paragraph summary and then quietly eats half a sprint. Federating Microsoft Entra ID into AWS Cognito is one of those.
The first 80% is well-trodden. There are docs and blog posts everywhere about wiring Cognito's SAML support up to an Entra app registration. SSO works, users sign in with their Microsoft account, the access token shows up on your API, everyone is happy.
The last 20% is the bit that quietly surprises people: the groups your authorisation actually depends on do not turn up where you expect them.
This is a write-up of how I solved that, in Terraform, on a project where the application needed to do RBAC against Entra groups without ever touching Microsoft Graph at runtime. The snippets below are the minimal honest version of the pattern, not a production-hardened module 🙂
The problem in plain English
When you federate Cognito with Entra over SAML and Entra's SAML assertion includes a groups claim, Cognito does receive the groups. They just do not end up on cognito:groups, because that claim is reserved for the native Cognito user-pool group concept.
Out of the box you have two unhappy options:
- Read the groups from a non-standard claim everywhere your application authorises, which means every consumer of the token has to learn about your specific federation choice
- Have the app call Microsoft Graph on every request to fetch group memberships, which is a latency, blast-radius and cost decision you probably do not want to be making
What I wanted was the third option: the access token already has cognito:groups populated with the user's Entra group IDs, and downstream APIs authorise on it like any other Cognito token.
The shape of the solution
Entra ID ──SAML──► Cognito User Pool ──pre-token Lambda──► JWT
(groups claim) (custom:entra_groups) (cognito:groups)
Three moving parts:
- A custom user-pool attribute (
custom:entra_groups) that the SAML attribute mapping writes the raw groups list into on sign-in - A pre-token-generation Lambda (V2_0 trigger) that reads
custom:entra_groupsat token-issuance time and rewritescognito:groupson both the ID and access tokens - A Terraform module that wires those together without leaking the awkward parts into the consuming stack
Calling the module ends up looking like this:
module "auth" {
source = "./modules/cognito-entra-federation"
name_prefix = "demo-app"
saml_metadata_url = "https://login.microsoftonline.com/<TENANT>/federationmetadata/2007-06/federationmetadata.xml?appid=<APP>"
app_callback_urls = ["https://app.example.com/auth/callback"]
app_logout_urls = ["https://app.example.com/logout"]
}
That is roughly all the consuming stack needs to know.
The fun bits
This is the section I enjoyed writing most, because every step here is a small gotcha that is obvious in retrospect and quietly painful the first time.
1. The user-pool schema is immutable once written
Cognito will not let you change a schema attribute after the pool exists. If Terraform tries to "fix" the schema on a later apply it will plan a destructive replacement of the entire pool, which is rarely what anyone wants on a Tuesday afternoon. The simplest defence is to tell Terraform to stop looking:
resource "aws_cognito_user_pool" "this" {
name = "${var.name_prefix}-users"
lifecycle {
ignore_changes = [schema]
}
schema {
name = "entra_groups"
attribute_data_type = "String"
mutable = true
string_attribute_constraints {
max_length = "4096"
}
}
}
2. MetadataURL populates other fields you must also ignore
When the SAML IdP is created with a MetadataURL, AWS fetches the XML in the background and stores the signing certificate, single-logout bindings and friends into the same provider_details map. On the next plan, Terraform sees those AWS-populated fields as drift and tries to remove them, which quietly breaks the IdP.
The fix is to ignore changes to provider_details, and make a deliberate change to the metadata URL still force a replacement:
resource "terraform_data" "metadata_url_pin" {
input = var.saml_metadata_url
}
resource "aws_cognito_identity_provider" "entra" {
provider_details = {
MetadataURL = var.saml_metadata_url
}
lifecycle {
ignore_changes = [provider_details]
replace_triggered_by = [terraform_data.metadata_url_pin.output]
}
}
This combination took me longer to land on than I would like to admit.
3. The V2_0 trigger is not optional
The legacy V1_0 pre-token-generation event can only modify the ID token. To also rewrite cognito:groups on the access token, the one your APIs almost always validate, you need V2_0:
lambda_config {
pre_token_generation_config {
lambda_arn = aws_lambda_function.pre_token.arn
lambda_version = "V2_0"
}
}
Forget this and you will spend an entertaining afternoon staring at a perfectly rewritten ID token while your API keeps rejecting the access token 😅
4. Entra's groups claim is not JSON
This one is my favourite. By the time the Entra group IDs land in custom:entra_groups, the value looks deceptively like an array:
[a1b2c3-..., d4e5f6-..., 7g8h9i-...]
json.loads will not parse it, because there are no quotes around the elements. So the Lambda does the only honest thing and strips the brackets and splits on commas:
def _parse_groups(raw: str) -> list[str]:
if not raw:
return []
text = raw.strip()
if text.startswith("[") and text.endswith("]"):
text = text[1:-1]
return [item.strip() for item in text.split(",") if item.strip()]
The handler itself is then refreshingly short, in the way good handlers tend to be:
def handler(event, _context):
attrs = event.get("request", {}).get("userAttributes", {}) or {}
groups = _parse_groups(attrs.get("custom:entra_groups", ""))
event["response"] = {
"claimsAndScopeOverrideDetails": {
"groupOverrideDetails": {
"groupsToOverride": groups,
}
}
}
return event
After this, every token Cognito issues for an Entra-federated user contains a cognito:groups claim with the user's Entra group IDs.
What this unlocks
Getting Entra groups onto a normal JWT claim sounds small, but it changes the shape of the next half-dozen problems that usually follow it.
A few things you can do once the groups are in the token:
- API Gateway Lambda authorisers doing real RBAC. A custom authoriser decodes the JWT, reads
cognito:groups, intersects with a per-route policy and returns an IAM policy document. No Graph calls, no second round-trip to anywhere, no extra caching layer to invalidate, and API Gateway will cache the authoriser response for a configurable TTL on top. - Frontend role-gating that matches the backend. The same claim is on the ID token, so a Vue or React app can hide actions, menu items, or whole routes based on the user's Entra groups, knowing the backend is enforcing the same answer for free.
- Fine-grained AWS access via a Cognito Identity Pool. Map specific Entra groups to IAM roles using Identity Pool group precedence, and a user's temporary AWS credentials end up scoped to exactly what their group memberships should allow, per-team S3 prefixes, per-team DynamoDB partitions, and so on, without per-team auth code.
- Audit logs that reference real org structure. If your audit pipeline persists the group claim alongside each action, your investigations get to talk about "Finance" or "EngineeringLeads" instead of opaque user IDs.
- Feature flags driven by group membership. Reading the claim once in middleware and feeding it into LaunchDarkly or a homegrown flag service means rollouts can target real org boundaries without the flag service needing its own user directory.
- Per-tenant data isolation. In a multi-tenant API where each tenant maps to an Entra group, the claim becomes the source of truth for row-level scoping in your queries, and the rule is enforced at the edge, not buried in the data layer.
The common thread is that the integration problem gets solved exactly once, at the auth boundary, and everything downstream gets to treat groups as a normal Cognito concept.
A caveat
The pattern itself is taken from a real client integration that worked. The snippets in this write-up are a re-write done for the article: minimal, opinionated, and not yet a tested end-to-end module. Treat them as a reference for the moving parts rather than a copy-paste production-ready setup, and you will not be sad.
If you ship something based on it, I would genuinely love to hear what hit you that wasn't covered here 🤝