[{"data":1,"prerenderedAt":2077},["Reactive",2],{"navbar-has-work":3,"all-articles":4},false,[5,1716],{"_path":6,"_dir":7,"_draft":3,"_partial":3,"_locale":8,"title":9,"description":10,"published":11,"slug":12,"body":13,"_type":1710,"_id":1711,"_source":1712,"_file":1713,"_stem":1714,"_extension":1715},"/articles/wiring-entra-id-into-aws-cognito","articles","","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.","2026/5/27","wiring-entra-id-into-aws-cognito",{"type":14,"children":15,"toc":1697},"root",[16,32,37,48,60,67,96,101,116,133,139,148,153,214,219,392,397,403,408,415,420,670,684,711,730,943,948,954,973,1071,1076,1082,1094,1102,1113,1340,1345,1558,1570,1576,1581,1586,1656,1668,1674,1686,1691],{"type":17,"tag":18,"props":19,"children":20},"element","p",{},[21,24,30],{"type":22,"value":23},"text","Every now and then a piece of integration work shows up that ",{"type":17,"tag":25,"props":26,"children":27},"em",{},[28],{"type":22,"value":29},"looks",{"type":22,"value":31}," simple from a one-paragraph summary and then quietly eats half a sprint. Federating Microsoft Entra ID into AWS Cognito is one of those.",{"type":17,"tag":18,"props":33,"children":34},{},[35],{"type":22,"value":36},"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.",{"type":17,"tag":18,"props":38,"children":39},{},[40,42],{"type":22,"value":41},"The last 20% is the bit that quietly surprises people: ",{"type":17,"tag":43,"props":44,"children":45},"strong",{},[46],{"type":22,"value":47},"the groups your authorisation actually depends on do not turn up where you expect them.",{"type":17,"tag":18,"props":49,"children":50},{},[51,53,58],{"type":22,"value":52},"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 ",{"type":17,"tag":25,"props":54,"children":55},{},[56],{"type":22,"value":57},"minimal honest version",{"type":22,"value":59}," of the pattern, not a production-hardened module 🙂",{"type":17,"tag":61,"props":62,"children":64},"h2",{"id":63},"the-problem-in-plain-english",[65],{"type":22,"value":66},"The problem in plain English",{"type":17,"tag":18,"props":68,"children":69},{},[70,72,79,81,87,89,94],{"type":22,"value":71},"When you federate Cognito with Entra over SAML and Entra's SAML assertion includes a ",{"type":17,"tag":73,"props":74,"children":76},"code",{"className":75},[],[77],{"type":22,"value":78},"groups",{"type":22,"value":80}," claim, Cognito does receive the groups. They just do not end up on ",{"type":17,"tag":73,"props":82,"children":84},{"className":83},[],[85],{"type":22,"value":86},"cognito:groups",{"type":22,"value":88},", because that claim is reserved for the ",{"type":17,"tag":25,"props":90,"children":91},{},[92],{"type":22,"value":93},"native",{"type":22,"value":95}," Cognito user-pool group concept.",{"type":17,"tag":18,"props":97,"children":98},{},[99],{"type":22,"value":100},"Out of the box you have two unhappy options:",{"type":17,"tag":102,"props":103,"children":104},"ul",{},[105,111],{"type":17,"tag":106,"props":107,"children":108},"li",{},[109],{"type":22,"value":110},"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",{"type":17,"tag":106,"props":112,"children":113},{},[114],{"type":22,"value":115},"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",{"type":17,"tag":18,"props":117,"children":118},{},[119,121],{"type":22,"value":120},"What I wanted was the third option: ",{"type":17,"tag":43,"props":122,"children":123},{},[124,126,131],{"type":22,"value":125},"the access token already has ",{"type":17,"tag":73,"props":127,"children":129},{"className":128},[],[130],{"type":22,"value":86},{"type":22,"value":132}," populated with the user's Entra group IDs, and downstream APIs authorise on it like any other Cognito token.",{"type":17,"tag":61,"props":134,"children":136},{"id":135},"the-shape-of-the-solution",[137],{"type":22,"value":138},"The shape of the solution",{"type":17,"tag":140,"props":141,"children":143},"pre",{"code":142},"   Entra ID ──SAML──►  Cognito User Pool ──pre-token Lambda──► JWT\n   (groups claim)      (custom:entra_groups)                   (cognito:groups)\n",[144],{"type":17,"tag":73,"props":145,"children":146},{"__ignoreMap":8},[147],{"type":22,"value":142},{"type":17,"tag":18,"props":149,"children":150},{},[151],{"type":22,"value":152},"Three moving parts:",{"type":17,"tag":154,"props":155,"children":156},"ol",{},[157,177,209],{"type":17,"tag":106,"props":158,"children":159},{},[160,162,167,169,175],{"type":22,"value":161},"A ",{"type":17,"tag":43,"props":163,"children":164},{},[165],{"type":22,"value":166},"custom user-pool attribute",{"type":22,"value":168}," (",{"type":17,"tag":73,"props":170,"children":172},{"className":171},[],[173],{"type":22,"value":174},"custom:entra_groups",{"type":22,"value":176},") that the SAML attribute mapping writes the raw groups list into on sign-in",{"type":17,"tag":106,"props":178,"children":179},{},[180,181,186,188,193,195,200,202,207],{"type":22,"value":161},{"type":17,"tag":43,"props":182,"children":183},{},[184],{"type":22,"value":185},"pre-token-generation Lambda",{"type":22,"value":187}," (V2_0 trigger) that reads ",{"type":17,"tag":73,"props":189,"children":191},{"className":190},[],[192],{"type":22,"value":174},{"type":22,"value":194}," at token-issuance time and rewrites ",{"type":17,"tag":73,"props":196,"children":198},{"className":197},[],[199],{"type":22,"value":86},{"type":22,"value":201}," on ",{"type":17,"tag":43,"props":203,"children":204},{},[205],{"type":22,"value":206},"both",{"type":22,"value":208}," the ID and access tokens",{"type":17,"tag":106,"props":210,"children":211},{},[212],{"type":22,"value":213},"A Terraform module that wires those together without leaking the awkward parts into the consuming stack",{"type":17,"tag":18,"props":215,"children":216},{},[217],{"type":22,"value":218},"Calling the module ends up looking like this:",{"type":17,"tag":140,"props":220,"children":224},{"code":221,"language":222,"meta":8,"className":223,"style":8},"module \"auth\" {\n  source = \"./modules/cognito-entra-federation\"\n\n  name_prefix       = \"demo-app\"\n  saml_metadata_url = \"https://login.microsoftonline.com/\u003CTENANT>/federationmetadata/2007-06/federationmetadata.xml?appid=\u003CAPP>\"\n\n  app_callback_urls = [\"https://app.example.com/auth/callback\"]\n  app_logout_urls   = [\"https://app.example.com/logout\"]\n}\n","hcl","language-hcl shiki shiki-themes github-light github-dark",[225],{"type":17,"tag":73,"props":226,"children":227},{"__ignoreMap":8},[228,252,273,283,302,320,328,356,383],{"type":17,"tag":229,"props":230,"children":233},"span",{"class":231,"line":232},"line",1,[234,240,246],{"type":17,"tag":229,"props":235,"children":237},{"style":236},"--shiki-default:#6F42C1;--shiki-dark:#B392F0",[238],{"type":22,"value":239},"module",{"type":17,"tag":229,"props":241,"children":243},{"style":242},"--shiki-default:#005CC5;--shiki-dark:#79B8FF",[244],{"type":22,"value":245}," \"auth\"",{"type":17,"tag":229,"props":247,"children":249},{"style":248},"--shiki-default:#24292E;--shiki-dark:#E1E4E8",[250],{"type":22,"value":251}," {\n",{"type":17,"tag":229,"props":253,"children":255},{"class":231,"line":254},2,[256,261,267],{"type":17,"tag":229,"props":257,"children":258},{"style":248},[259],{"type":22,"value":260},"  source",{"type":17,"tag":229,"props":262,"children":264},{"style":263},"--shiki-default:#D73A49;--shiki-dark:#F97583",[265],{"type":22,"value":266}," =",{"type":17,"tag":229,"props":268,"children":270},{"style":269},"--shiki-default:#032F62;--shiki-dark:#9ECBFF",[271],{"type":22,"value":272}," \"./modules/cognito-entra-federation\"\n",{"type":17,"tag":229,"props":274,"children":276},{"class":231,"line":275},3,[277],{"type":17,"tag":229,"props":278,"children":280},{"emptyLinePlaceholder":279},true,[281],{"type":22,"value":282},"\n",{"type":17,"tag":229,"props":284,"children":286},{"class":231,"line":285},4,[287,292,297],{"type":17,"tag":229,"props":288,"children":289},{"style":248},[290],{"type":22,"value":291},"  name_prefix",{"type":17,"tag":229,"props":293,"children":294},{"style":263},[295],{"type":22,"value":296},"       =",{"type":17,"tag":229,"props":298,"children":299},{"style":269},[300],{"type":22,"value":301}," \"demo-app\"\n",{"type":17,"tag":229,"props":303,"children":305},{"class":231,"line":304},5,[306,311,315],{"type":17,"tag":229,"props":307,"children":308},{"style":248},[309],{"type":22,"value":310},"  saml_metadata_url",{"type":17,"tag":229,"props":312,"children":313},{"style":263},[314],{"type":22,"value":266},{"type":17,"tag":229,"props":316,"children":317},{"style":269},[318],{"type":22,"value":319}," \"https://login.microsoftonline.com/\u003CTENANT>/federationmetadata/2007-06/federationmetadata.xml?appid=\u003CAPP>\"\n",{"type":17,"tag":229,"props":321,"children":323},{"class":231,"line":322},6,[324],{"type":17,"tag":229,"props":325,"children":326},{"emptyLinePlaceholder":279},[327],{"type":22,"value":282},{"type":17,"tag":229,"props":329,"children":331},{"class":231,"line":330},7,[332,337,341,346,351],{"type":17,"tag":229,"props":333,"children":334},{"style":248},[335],{"type":22,"value":336},"  app_callback_urls",{"type":17,"tag":229,"props":338,"children":339},{"style":263},[340],{"type":22,"value":266},{"type":17,"tag":229,"props":342,"children":343},{"style":248},[344],{"type":22,"value":345}," [",{"type":17,"tag":229,"props":347,"children":348},{"style":269},[349],{"type":22,"value":350},"\"https://app.example.com/auth/callback\"",{"type":17,"tag":229,"props":352,"children":353},{"style":248},[354],{"type":22,"value":355},"]\n",{"type":17,"tag":229,"props":357,"children":359},{"class":231,"line":358},8,[360,365,370,374,379],{"type":17,"tag":229,"props":361,"children":362},{"style":248},[363],{"type":22,"value":364},"  app_logout_urls",{"type":17,"tag":229,"props":366,"children":367},{"style":263},[368],{"type":22,"value":369},"   =",{"type":17,"tag":229,"props":371,"children":372},{"style":248},[373],{"type":22,"value":345},{"type":17,"tag":229,"props":375,"children":376},{"style":269},[377],{"type":22,"value":378},"\"https://app.example.com/logout\"",{"type":17,"tag":229,"props":380,"children":381},{"style":248},[382],{"type":22,"value":355},{"type":17,"tag":229,"props":384,"children":386},{"class":231,"line":385},9,[387],{"type":17,"tag":229,"props":388,"children":389},{"style":248},[390],{"type":22,"value":391},"}\n",{"type":17,"tag":18,"props":393,"children":394},{},[395],{"type":22,"value":396},"That is roughly all the consuming stack needs to know.",{"type":17,"tag":61,"props":398,"children":400},{"id":399},"the-fun-bits",[401],{"type":22,"value":402},"The fun bits",{"type":17,"tag":18,"props":404,"children":405},{},[406],{"type":22,"value":407},"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.",{"type":17,"tag":409,"props":410,"children":412},"h3",{"id":411},"_1-the-user-pool-schema-is-immutable-once-written",[413],{"type":22,"value":414},"1. The user-pool schema is immutable once written",{"type":17,"tag":18,"props":416,"children":417},{},[418],{"type":22,"value":419},"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:",{"type":17,"tag":140,"props":421,"children":423},{"code":422,"language":222,"meta":8,"className":223,"style":8},"resource \"aws_cognito_user_pool\" \"this\" {\n  name = \"${var.name_prefix}-users\"\n\n  lifecycle {\n    ignore_changes = [schema]\n  }\n\n  schema {\n    name                = \"entra_groups\"\n    attribute_data_type = \"String\"\n    mutable             = true\n    string_attribute_constraints {\n      max_length = \"4096\"\n    }\n  }\n}\n",[424],{"type":17,"tag":73,"props":425,"children":426},{"__ignoreMap":8},[427,449,496,503,515,532,540,547,559,577,595,614,627,645,654,662],{"type":17,"tag":229,"props":428,"children":429},{"class":231,"line":232},[430,435,440,445],{"type":17,"tag":229,"props":431,"children":432},{"style":236},[433],{"type":22,"value":434},"resource",{"type":17,"tag":229,"props":436,"children":437},{"style":242},[438],{"type":22,"value":439}," \"aws_cognito_user_pool\"",{"type":17,"tag":229,"props":441,"children":442},{"style":242},[443],{"type":22,"value":444}," \"this\"",{"type":17,"tag":229,"props":446,"children":447},{"style":248},[448],{"type":22,"value":251},{"type":17,"tag":229,"props":450,"children":451},{"class":231,"line":254},[452,457,461,466,471,476,481,486,491],{"type":17,"tag":229,"props":453,"children":454},{"style":248},[455],{"type":22,"value":456},"  name",{"type":17,"tag":229,"props":458,"children":459},{"style":263},[460],{"type":22,"value":266},{"type":17,"tag":229,"props":462,"children":463},{"style":269},[464],{"type":22,"value":465}," \"",{"type":17,"tag":229,"props":467,"children":468},{"style":263},[469],{"type":22,"value":470},"${",{"type":17,"tag":229,"props":472,"children":473},{"style":248},[474],{"type":22,"value":475},"var",{"type":17,"tag":229,"props":477,"children":478},{"style":263},[479],{"type":22,"value":480},".",{"type":17,"tag":229,"props":482,"children":483},{"style":248},[484],{"type":22,"value":485},"name_prefix",{"type":17,"tag":229,"props":487,"children":488},{"style":263},[489],{"type":22,"value":490},"}",{"type":17,"tag":229,"props":492,"children":493},{"style":269},[494],{"type":22,"value":495},"-users\"\n",{"type":17,"tag":229,"props":497,"children":498},{"class":231,"line":275},[499],{"type":17,"tag":229,"props":500,"children":501},{"emptyLinePlaceholder":279},[502],{"type":22,"value":282},{"type":17,"tag":229,"props":504,"children":505},{"class":231,"line":285},[506,511],{"type":17,"tag":229,"props":507,"children":508},{"style":236},[509],{"type":22,"value":510},"  lifecycle",{"type":17,"tag":229,"props":512,"children":513},{"style":248},[514],{"type":22,"value":251},{"type":17,"tag":229,"props":516,"children":517},{"class":231,"line":304},[518,523,527],{"type":17,"tag":229,"props":519,"children":520},{"style":248},[521],{"type":22,"value":522},"    ignore_changes",{"type":17,"tag":229,"props":524,"children":525},{"style":263},[526],{"type":22,"value":266},{"type":17,"tag":229,"props":528,"children":529},{"style":248},[530],{"type":22,"value":531}," [schema]\n",{"type":17,"tag":229,"props":533,"children":534},{"class":231,"line":322},[535],{"type":17,"tag":229,"props":536,"children":537},{"style":248},[538],{"type":22,"value":539},"  }\n",{"type":17,"tag":229,"props":541,"children":542},{"class":231,"line":330},[543],{"type":17,"tag":229,"props":544,"children":545},{"emptyLinePlaceholder":279},[546],{"type":22,"value":282},{"type":17,"tag":229,"props":548,"children":549},{"class":231,"line":358},[550,555],{"type":17,"tag":229,"props":551,"children":552},{"style":236},[553],{"type":22,"value":554},"  schema",{"type":17,"tag":229,"props":556,"children":557},{"style":248},[558],{"type":22,"value":251},{"type":17,"tag":229,"props":560,"children":561},{"class":231,"line":385},[562,567,572],{"type":17,"tag":229,"props":563,"children":564},{"style":248},[565],{"type":22,"value":566},"    name",{"type":17,"tag":229,"props":568,"children":569},{"style":263},[570],{"type":22,"value":571},"                =",{"type":17,"tag":229,"props":573,"children":574},{"style":269},[575],{"type":22,"value":576}," \"entra_groups\"\n",{"type":17,"tag":229,"props":578,"children":580},{"class":231,"line":579},10,[581,586,590],{"type":17,"tag":229,"props":582,"children":583},{"style":248},[584],{"type":22,"value":585},"    attribute_data_type",{"type":17,"tag":229,"props":587,"children":588},{"style":263},[589],{"type":22,"value":266},{"type":17,"tag":229,"props":591,"children":592},{"style":269},[593],{"type":22,"value":594}," \"String\"\n",{"type":17,"tag":229,"props":596,"children":598},{"class":231,"line":597},11,[599,604,609],{"type":17,"tag":229,"props":600,"children":601},{"style":248},[602],{"type":22,"value":603},"    mutable",{"type":17,"tag":229,"props":605,"children":606},{"style":263},[607],{"type":22,"value":608},"             =",{"type":17,"tag":229,"props":610,"children":611},{"style":242},[612],{"type":22,"value":613}," true\n",{"type":17,"tag":229,"props":615,"children":617},{"class":231,"line":616},12,[618,623],{"type":17,"tag":229,"props":619,"children":620},{"style":236},[621],{"type":22,"value":622},"    string_attribute_constraints",{"type":17,"tag":229,"props":624,"children":625},{"style":248},[626],{"type":22,"value":251},{"type":17,"tag":229,"props":628,"children":630},{"class":231,"line":629},13,[631,636,640],{"type":17,"tag":229,"props":632,"children":633},{"style":248},[634],{"type":22,"value":635},"      max_length",{"type":17,"tag":229,"props":637,"children":638},{"style":263},[639],{"type":22,"value":266},{"type":17,"tag":229,"props":641,"children":642},{"style":269},[643],{"type":22,"value":644}," \"4096\"\n",{"type":17,"tag":229,"props":646,"children":648},{"class":231,"line":647},14,[649],{"type":17,"tag":229,"props":650,"children":651},{"style":248},[652],{"type":22,"value":653},"    }\n",{"type":17,"tag":229,"props":655,"children":657},{"class":231,"line":656},15,[658],{"type":17,"tag":229,"props":659,"children":660},{"style":248},[661],{"type":22,"value":539},{"type":17,"tag":229,"props":663,"children":665},{"class":231,"line":664},16,[666],{"type":17,"tag":229,"props":667,"children":668},{"style":248},[669],{"type":22,"value":391},{"type":17,"tag":409,"props":671,"children":673},{"id":672},"_2-metadataurl-populates-other-fields-you-must-also-ignore",[674,676,682],{"type":22,"value":675},"2. ",{"type":17,"tag":73,"props":677,"children":679},{"className":678},[],[680],{"type":22,"value":681},"MetadataURL",{"type":22,"value":683}," populates other fields you must also ignore",{"type":17,"tag":18,"props":685,"children":686},{},[687,689,694,696,701,703,709],{"type":22,"value":688},"When the SAML IdP is created with a ",{"type":17,"tag":73,"props":690,"children":692},{"className":691},[],[693],{"type":22,"value":681},{"type":22,"value":695},", AWS fetches the XML in the background and stores the signing certificate, single-logout bindings and friends into the ",{"type":17,"tag":25,"props":697,"children":698},{},[699],{"type":22,"value":700},"same",{"type":22,"value":702}," ",{"type":17,"tag":73,"props":704,"children":706},{"className":705},[],[707],{"type":22,"value":708},"provider_details",{"type":22,"value":710}," map. On the next plan, Terraform sees those AWS-populated fields as drift and tries to remove them, which quietly breaks the IdP.",{"type":17,"tag":18,"props":712,"children":713},{},[714,716,721,723,728],{"type":22,"value":715},"The fix is to ignore changes to ",{"type":17,"tag":73,"props":717,"children":719},{"className":718},[],[720],{"type":22,"value":708},{"type":22,"value":722},", ",{"type":17,"tag":43,"props":724,"children":725},{},[726],{"type":22,"value":727},"and",{"type":22,"value":729}," make a deliberate change to the metadata URL still force a replacement:",{"type":17,"tag":140,"props":731,"children":733},{"code":732,"language":222,"meta":8,"className":223,"style":8},"resource \"terraform_data\" \"metadata_url_pin\" {\n  input = var.saml_metadata_url\n}\n\nresource \"aws_cognito_identity_provider\" \"entra\" {\n  provider_details = {\n    MetadataURL = var.saml_metadata_url\n  }\n\n  lifecycle {\n    ignore_changes       = [provider_details]\n    replace_triggered_by = [terraform_data.metadata_url_pin.output]\n  }\n}\n",[734],{"type":17,"tag":73,"props":735,"children":736},{"__ignoreMap":8},[737,758,784,791,798,819,835,853,860,867,878,894,929,936],{"type":17,"tag":229,"props":738,"children":739},{"class":231,"line":232},[740,744,749,754],{"type":17,"tag":229,"props":741,"children":742},{"style":236},[743],{"type":22,"value":434},{"type":17,"tag":229,"props":745,"children":746},{"style":242},[747],{"type":22,"value":748}," \"terraform_data\"",{"type":17,"tag":229,"props":750,"children":751},{"style":242},[752],{"type":22,"value":753}," \"metadata_url_pin\"",{"type":17,"tag":229,"props":755,"children":756},{"style":248},[757],{"type":22,"value":251},{"type":17,"tag":229,"props":759,"children":760},{"class":231,"line":254},[761,766,770,775,779],{"type":17,"tag":229,"props":762,"children":763},{"style":248},[764],{"type":22,"value":765},"  input",{"type":17,"tag":229,"props":767,"children":768},{"style":263},[769],{"type":22,"value":266},{"type":17,"tag":229,"props":771,"children":772},{"style":248},[773],{"type":22,"value":774}," var",{"type":17,"tag":229,"props":776,"children":777},{"style":263},[778],{"type":22,"value":480},{"type":17,"tag":229,"props":780,"children":781},{"style":248},[782],{"type":22,"value":783},"saml_metadata_url\n",{"type":17,"tag":229,"props":785,"children":786},{"class":231,"line":275},[787],{"type":17,"tag":229,"props":788,"children":789},{"style":248},[790],{"type":22,"value":391},{"type":17,"tag":229,"props":792,"children":793},{"class":231,"line":285},[794],{"type":17,"tag":229,"props":795,"children":796},{"emptyLinePlaceholder":279},[797],{"type":22,"value":282},{"type":17,"tag":229,"props":799,"children":800},{"class":231,"line":304},[801,805,810,815],{"type":17,"tag":229,"props":802,"children":803},{"style":236},[804],{"type":22,"value":434},{"type":17,"tag":229,"props":806,"children":807},{"style":242},[808],{"type":22,"value":809}," \"aws_cognito_identity_provider\"",{"type":17,"tag":229,"props":811,"children":812},{"style":242},[813],{"type":22,"value":814}," \"entra\"",{"type":17,"tag":229,"props":816,"children":817},{"style":248},[818],{"type":22,"value":251},{"type":17,"tag":229,"props":820,"children":821},{"class":231,"line":322},[822,827,831],{"type":17,"tag":229,"props":823,"children":824},{"style":248},[825],{"type":22,"value":826},"  provider_details",{"type":17,"tag":229,"props":828,"children":829},{"style":263},[830],{"type":22,"value":266},{"type":17,"tag":229,"props":832,"children":833},{"style":248},[834],{"type":22,"value":251},{"type":17,"tag":229,"props":836,"children":837},{"class":231,"line":330},[838,843,848],{"type":17,"tag":229,"props":839,"children":840},{"style":248},[841],{"type":22,"value":842},"    MetadataURL ",{"type":17,"tag":229,"props":844,"children":845},{"style":263},[846],{"type":22,"value":847},"=",{"type":17,"tag":229,"props":849,"children":850},{"style":248},[851],{"type":22,"value":852}," var.saml_metadata_url\n",{"type":17,"tag":229,"props":854,"children":855},{"class":231,"line":358},[856],{"type":17,"tag":229,"props":857,"children":858},{"style":248},[859],{"type":22,"value":539},{"type":17,"tag":229,"props":861,"children":862},{"class":231,"line":385},[863],{"type":17,"tag":229,"props":864,"children":865},{"emptyLinePlaceholder":279},[866],{"type":22,"value":282},{"type":17,"tag":229,"props":868,"children":869},{"class":231,"line":579},[870,874],{"type":17,"tag":229,"props":871,"children":872},{"style":236},[873],{"type":22,"value":510},{"type":17,"tag":229,"props":875,"children":876},{"style":248},[877],{"type":22,"value":251},{"type":17,"tag":229,"props":879,"children":880},{"class":231,"line":597},[881,885,889],{"type":17,"tag":229,"props":882,"children":883},{"style":248},[884],{"type":22,"value":522},{"type":17,"tag":229,"props":886,"children":887},{"style":263},[888],{"type":22,"value":296},{"type":17,"tag":229,"props":890,"children":891},{"style":248},[892],{"type":22,"value":893}," [provider_details]\n",{"type":17,"tag":229,"props":895,"children":896},{"class":231,"line":616},[897,902,906,911,915,920,924],{"type":17,"tag":229,"props":898,"children":899},{"style":248},[900],{"type":22,"value":901},"    replace_triggered_by",{"type":17,"tag":229,"props":903,"children":904},{"style":263},[905],{"type":22,"value":266},{"type":17,"tag":229,"props":907,"children":908},{"style":248},[909],{"type":22,"value":910}," [terraform_data",{"type":17,"tag":229,"props":912,"children":913},{"style":263},[914],{"type":22,"value":480},{"type":17,"tag":229,"props":916,"children":917},{"style":248},[918],{"type":22,"value":919},"metadata_url_pin",{"type":17,"tag":229,"props":921,"children":922},{"style":263},[923],{"type":22,"value":480},{"type":17,"tag":229,"props":925,"children":926},{"style":248},[927],{"type":22,"value":928},"output]\n",{"type":17,"tag":229,"props":930,"children":931},{"class":231,"line":629},[932],{"type":17,"tag":229,"props":933,"children":934},{"style":248},[935],{"type":22,"value":539},{"type":17,"tag":229,"props":937,"children":938},{"class":231,"line":647},[939],{"type":17,"tag":229,"props":940,"children":941},{"style":248},[942],{"type":22,"value":391},{"type":17,"tag":18,"props":944,"children":945},{},[946],{"type":22,"value":947},"This combination took me longer to land on than I would like to admit.",{"type":17,"tag":409,"props":949,"children":951},{"id":950},"_3-the-v2_0-trigger-is-not-optional",[952],{"type":22,"value":953},"3. The V2_0 trigger is not optional",{"type":17,"tag":18,"props":955,"children":956},{},[957,959,964,966,971],{"type":22,"value":958},"The legacy V1_0 pre-token-generation event can only modify the ID token. To also rewrite ",{"type":17,"tag":73,"props":960,"children":962},{"className":961},[],[963],{"type":22,"value":86},{"type":22,"value":965}," on the ",{"type":17,"tag":43,"props":967,"children":968},{},[969],{"type":22,"value":970},"access token",{"type":22,"value":972},", the one your APIs almost always validate, you need V2_0:",{"type":17,"tag":140,"props":974,"children":976},{"code":975,"language":222,"meta":8,"className":223,"style":8},"lambda_config {\n  pre_token_generation_config {\n    lambda_arn     = aws_lambda_function.pre_token.arn\n    lambda_version = \"V2_0\"\n  }\n}\n",[977],{"type":17,"tag":73,"props":978,"children":979},{"__ignoreMap":8},[980,992,1004,1040,1057,1064],{"type":17,"tag":229,"props":981,"children":982},{"class":231,"line":232},[983,988],{"type":17,"tag":229,"props":984,"children":985},{"style":236},[986],{"type":22,"value":987},"lambda_config",{"type":17,"tag":229,"props":989,"children":990},{"style":248},[991],{"type":22,"value":251},{"type":17,"tag":229,"props":993,"children":994},{"class":231,"line":254},[995,1000],{"type":17,"tag":229,"props":996,"children":997},{"style":236},[998],{"type":22,"value":999},"  pre_token_generation_config",{"type":17,"tag":229,"props":1001,"children":1002},{"style":248},[1003],{"type":22,"value":251},{"type":17,"tag":229,"props":1005,"children":1006},{"class":231,"line":275},[1007,1012,1017,1022,1026,1031,1035],{"type":17,"tag":229,"props":1008,"children":1009},{"style":248},[1010],{"type":22,"value":1011},"    lambda_arn",{"type":17,"tag":229,"props":1013,"children":1014},{"style":263},[1015],{"type":22,"value":1016},"     =",{"type":17,"tag":229,"props":1018,"children":1019},{"style":248},[1020],{"type":22,"value":1021}," aws_lambda_function",{"type":17,"tag":229,"props":1023,"children":1024},{"style":263},[1025],{"type":22,"value":480},{"type":17,"tag":229,"props":1027,"children":1028},{"style":248},[1029],{"type":22,"value":1030},"pre_token",{"type":17,"tag":229,"props":1032,"children":1033},{"style":263},[1034],{"type":22,"value":480},{"type":17,"tag":229,"props":1036,"children":1037},{"style":248},[1038],{"type":22,"value":1039},"arn\n",{"type":17,"tag":229,"props":1041,"children":1042},{"class":231,"line":285},[1043,1048,1052],{"type":17,"tag":229,"props":1044,"children":1045},{"style":248},[1046],{"type":22,"value":1047},"    lambda_version",{"type":17,"tag":229,"props":1049,"children":1050},{"style":263},[1051],{"type":22,"value":266},{"type":17,"tag":229,"props":1053,"children":1054},{"style":269},[1055],{"type":22,"value":1056}," \"V2_0\"\n",{"type":17,"tag":229,"props":1058,"children":1059},{"class":231,"line":304},[1060],{"type":17,"tag":229,"props":1061,"children":1062},{"style":248},[1063],{"type":22,"value":539},{"type":17,"tag":229,"props":1065,"children":1066},{"class":231,"line":322},[1067],{"type":17,"tag":229,"props":1068,"children":1069},{"style":248},[1070],{"type":22,"value":391},{"type":17,"tag":18,"props":1072,"children":1073},{},[1074],{"type":22,"value":1075},"Forget this and you will spend an entertaining afternoon staring at a perfectly rewritten ID token while your API keeps rejecting the access token 😅",{"type":17,"tag":409,"props":1077,"children":1079},{"id":1078},"_4-entras-groups-claim-is-not-json",[1080],{"type":22,"value":1081},"4. Entra's groups claim is not JSON",{"type":17,"tag":18,"props":1083,"children":1084},{},[1085,1087,1092],{"type":22,"value":1086},"This one is my favourite. By the time the Entra group IDs land in ",{"type":17,"tag":73,"props":1088,"children":1090},{"className":1089},[],[1091],{"type":22,"value":174},{"type":22,"value":1093},", the value looks deceptively like an array:",{"type":17,"tag":140,"props":1095,"children":1097},{"code":1096},"[a1b2c3-..., d4e5f6-..., 7g8h9i-...]\n",[1098],{"type":17,"tag":73,"props":1099,"children":1100},{"__ignoreMap":8},[1101],{"type":22,"value":1096},{"type":17,"tag":18,"props":1103,"children":1104},{},[1105,1111],{"type":17,"tag":73,"props":1106,"children":1108},{"className":1107},[],[1109],{"type":22,"value":1110},"json.loads",{"type":22,"value":1112}," 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:",{"type":17,"tag":140,"props":1114,"children":1118},{"code":1115,"language":1116,"meta":8,"className":1117,"style":8},"def _parse_groups(raw: str) -> list[str]:\n    if not raw:\n        return []\n    text = raw.strip()\n    if text.startswith(\"[\") and text.endswith(\"]\"):\n        text = text[1:-1]\n    return [item.strip() for item in text.split(\",\") if item.strip()]\n","python","language-python shiki shiki-themes github-light github-dark",[1119],{"type":17,"tag":73,"props":1120,"children":1121},{"__ignoreMap":8},[1122,1159,1177,1190,1207,1248,1288],{"type":17,"tag":229,"props":1123,"children":1124},{"class":231,"line":232},[1125,1130,1135,1140,1145,1150,1154],{"type":17,"tag":229,"props":1126,"children":1127},{"style":263},[1128],{"type":22,"value":1129},"def",{"type":17,"tag":229,"props":1131,"children":1132},{"style":236},[1133],{"type":22,"value":1134}," _parse_groups",{"type":17,"tag":229,"props":1136,"children":1137},{"style":248},[1138],{"type":22,"value":1139},"(raw: ",{"type":17,"tag":229,"props":1141,"children":1142},{"style":242},[1143],{"type":22,"value":1144},"str",{"type":17,"tag":229,"props":1146,"children":1147},{"style":248},[1148],{"type":22,"value":1149},") -> list[",{"type":17,"tag":229,"props":1151,"children":1152},{"style":242},[1153],{"type":22,"value":1144},{"type":17,"tag":229,"props":1155,"children":1156},{"style":248},[1157],{"type":22,"value":1158},"]:\n",{"type":17,"tag":229,"props":1160,"children":1161},{"class":231,"line":254},[1162,1167,1172],{"type":17,"tag":229,"props":1163,"children":1164},{"style":263},[1165],{"type":22,"value":1166},"    if",{"type":17,"tag":229,"props":1168,"children":1169},{"style":263},[1170],{"type":22,"value":1171}," not",{"type":17,"tag":229,"props":1173,"children":1174},{"style":248},[1175],{"type":22,"value":1176}," raw:\n",{"type":17,"tag":229,"props":1178,"children":1179},{"class":231,"line":275},[1180,1185],{"type":17,"tag":229,"props":1181,"children":1182},{"style":263},[1183],{"type":22,"value":1184},"        return",{"type":17,"tag":229,"props":1186,"children":1187},{"style":248},[1188],{"type":22,"value":1189}," []\n",{"type":17,"tag":229,"props":1191,"children":1192},{"class":231,"line":285},[1193,1198,1202],{"type":17,"tag":229,"props":1194,"children":1195},{"style":248},[1196],{"type":22,"value":1197},"    text ",{"type":17,"tag":229,"props":1199,"children":1200},{"style":263},[1201],{"type":22,"value":847},{"type":17,"tag":229,"props":1203,"children":1204},{"style":248},[1205],{"type":22,"value":1206}," raw.strip()\n",{"type":17,"tag":229,"props":1208,"children":1209},{"class":231,"line":304},[1210,1214,1219,1224,1229,1233,1238,1243],{"type":17,"tag":229,"props":1211,"children":1212},{"style":263},[1213],{"type":22,"value":1166},{"type":17,"tag":229,"props":1215,"children":1216},{"style":248},[1217],{"type":22,"value":1218}," text.startswith(",{"type":17,"tag":229,"props":1220,"children":1221},{"style":269},[1222],{"type":22,"value":1223},"\"[\"",{"type":17,"tag":229,"props":1225,"children":1226},{"style":248},[1227],{"type":22,"value":1228},") ",{"type":17,"tag":229,"props":1230,"children":1231},{"style":263},[1232],{"type":22,"value":727},{"type":17,"tag":229,"props":1234,"children":1235},{"style":248},[1236],{"type":22,"value":1237}," text.endswith(",{"type":17,"tag":229,"props":1239,"children":1240},{"style":269},[1241],{"type":22,"value":1242},"\"]\"",{"type":17,"tag":229,"props":1244,"children":1245},{"style":248},[1246],{"type":22,"value":1247},"):\n",{"type":17,"tag":229,"props":1249,"children":1250},{"class":231,"line":322},[1251,1256,1260,1265,1270,1275,1280,1284],{"type":17,"tag":229,"props":1252,"children":1253},{"style":248},[1254],{"type":22,"value":1255},"        text ",{"type":17,"tag":229,"props":1257,"children":1258},{"style":263},[1259],{"type":22,"value":847},{"type":17,"tag":229,"props":1261,"children":1262},{"style":248},[1263],{"type":22,"value":1264}," text[",{"type":17,"tag":229,"props":1266,"children":1267},{"style":242},[1268],{"type":22,"value":1269},"1",{"type":17,"tag":229,"props":1271,"children":1272},{"style":248},[1273],{"type":22,"value":1274},":",{"type":17,"tag":229,"props":1276,"children":1277},{"style":263},[1278],{"type":22,"value":1279},"-",{"type":17,"tag":229,"props":1281,"children":1282},{"style":242},[1283],{"type":22,"value":1269},{"type":17,"tag":229,"props":1285,"children":1286},{"style":248},[1287],{"type":22,"value":355},{"type":17,"tag":229,"props":1289,"children":1290},{"class":231,"line":330},[1291,1296,1301,1306,1311,1316,1321,1326,1330,1335],{"type":17,"tag":229,"props":1292,"children":1293},{"style":263},[1294],{"type":22,"value":1295},"    return",{"type":17,"tag":229,"props":1297,"children":1298},{"style":248},[1299],{"type":22,"value":1300}," [item.strip() ",{"type":17,"tag":229,"props":1302,"children":1303},{"style":263},[1304],{"type":22,"value":1305},"for",{"type":17,"tag":229,"props":1307,"children":1308},{"style":248},[1309],{"type":22,"value":1310}," item ",{"type":17,"tag":229,"props":1312,"children":1313},{"style":263},[1314],{"type":22,"value":1315},"in",{"type":17,"tag":229,"props":1317,"children":1318},{"style":248},[1319],{"type":22,"value":1320}," text.split(",{"type":17,"tag":229,"props":1322,"children":1323},{"style":269},[1324],{"type":22,"value":1325},"\",\"",{"type":17,"tag":229,"props":1327,"children":1328},{"style":248},[1329],{"type":22,"value":1228},{"type":17,"tag":229,"props":1331,"children":1332},{"style":263},[1333],{"type":22,"value":1334},"if",{"type":17,"tag":229,"props":1336,"children":1337},{"style":248},[1338],{"type":22,"value":1339}," item.strip()]\n",{"type":17,"tag":18,"props":1341,"children":1342},{},[1343],{"type":22,"value":1344},"The handler itself is then refreshingly short, in the way good handlers tend to be:",{"type":17,"tag":140,"props":1346,"children":1348},{"code":1347,"language":1116,"meta":8,"className":1117,"style":8},"def handler(event, _context):\n    attrs  = event.get(\"request\", {}).get(\"userAttributes\", {}) or {}\n    groups = _parse_groups(attrs.get(\"custom:entra_groups\", \"\"))\n\n    event[\"response\"] = {\n        \"claimsAndScopeOverrideDetails\": {\n            \"groupOverrideDetails\": {\n                \"groupsToOverride\": groups,\n            }\n        }\n    }\n    return event\n",[1349],{"type":17,"tag":73,"props":1350,"children":1351},{"__ignoreMap":8},[1352,1369,1416,1452,1459,1485,1498,1510,1523,1531,1539,1546],{"type":17,"tag":229,"props":1353,"children":1354},{"class":231,"line":232},[1355,1359,1364],{"type":17,"tag":229,"props":1356,"children":1357},{"style":263},[1358],{"type":22,"value":1129},{"type":17,"tag":229,"props":1360,"children":1361},{"style":236},[1362],{"type":22,"value":1363}," handler",{"type":17,"tag":229,"props":1365,"children":1366},{"style":248},[1367],{"type":22,"value":1368},"(event, _context):\n",{"type":17,"tag":229,"props":1370,"children":1371},{"class":231,"line":254},[1372,1377,1381,1386,1391,1396,1401,1406,1411],{"type":17,"tag":229,"props":1373,"children":1374},{"style":248},[1375],{"type":22,"value":1376},"    attrs  ",{"type":17,"tag":229,"props":1378,"children":1379},{"style":263},[1380],{"type":22,"value":847},{"type":17,"tag":229,"props":1382,"children":1383},{"style":248},[1384],{"type":22,"value":1385}," event.get(",{"type":17,"tag":229,"props":1387,"children":1388},{"style":269},[1389],{"type":22,"value":1390},"\"request\"",{"type":17,"tag":229,"props":1392,"children":1393},{"style":248},[1394],{"type":22,"value":1395},", {}).get(",{"type":17,"tag":229,"props":1397,"children":1398},{"style":269},[1399],{"type":22,"value":1400},"\"userAttributes\"",{"type":17,"tag":229,"props":1402,"children":1403},{"style":248},[1404],{"type":22,"value":1405},", {}) ",{"type":17,"tag":229,"props":1407,"children":1408},{"style":263},[1409],{"type":22,"value":1410},"or",{"type":17,"tag":229,"props":1412,"children":1413},{"style":248},[1414],{"type":22,"value":1415}," {}\n",{"type":17,"tag":229,"props":1417,"children":1418},{"class":231,"line":275},[1419,1424,1428,1433,1438,1442,1447],{"type":17,"tag":229,"props":1420,"children":1421},{"style":248},[1422],{"type":22,"value":1423},"    groups ",{"type":17,"tag":229,"props":1425,"children":1426},{"style":263},[1427],{"type":22,"value":847},{"type":17,"tag":229,"props":1429,"children":1430},{"style":248},[1431],{"type":22,"value":1432}," _parse_groups(attrs.get(",{"type":17,"tag":229,"props":1434,"children":1435},{"style":269},[1436],{"type":22,"value":1437},"\"custom:entra_groups\"",{"type":17,"tag":229,"props":1439,"children":1440},{"style":248},[1441],{"type":22,"value":722},{"type":17,"tag":229,"props":1443,"children":1444},{"style":269},[1445],{"type":22,"value":1446},"\"\"",{"type":17,"tag":229,"props":1448,"children":1449},{"style":248},[1450],{"type":22,"value":1451},"))\n",{"type":17,"tag":229,"props":1453,"children":1454},{"class":231,"line":285},[1455],{"type":17,"tag":229,"props":1456,"children":1457},{"emptyLinePlaceholder":279},[1458],{"type":22,"value":282},{"type":17,"tag":229,"props":1460,"children":1461},{"class":231,"line":304},[1462,1467,1472,1477,1481],{"type":17,"tag":229,"props":1463,"children":1464},{"style":248},[1465],{"type":22,"value":1466},"    event[",{"type":17,"tag":229,"props":1468,"children":1469},{"style":269},[1470],{"type":22,"value":1471},"\"response\"",{"type":17,"tag":229,"props":1473,"children":1474},{"style":248},[1475],{"type":22,"value":1476},"] ",{"type":17,"tag":229,"props":1478,"children":1479},{"style":263},[1480],{"type":22,"value":847},{"type":17,"tag":229,"props":1482,"children":1483},{"style":248},[1484],{"type":22,"value":251},{"type":17,"tag":229,"props":1486,"children":1487},{"class":231,"line":322},[1488,1493],{"type":17,"tag":229,"props":1489,"children":1490},{"style":269},[1491],{"type":22,"value":1492},"        \"claimsAndScopeOverrideDetails\"",{"type":17,"tag":229,"props":1494,"children":1495},{"style":248},[1496],{"type":22,"value":1497},": {\n",{"type":17,"tag":229,"props":1499,"children":1500},{"class":231,"line":330},[1501,1506],{"type":17,"tag":229,"props":1502,"children":1503},{"style":269},[1504],{"type":22,"value":1505},"            \"groupOverrideDetails\"",{"type":17,"tag":229,"props":1507,"children":1508},{"style":248},[1509],{"type":22,"value":1497},{"type":17,"tag":229,"props":1511,"children":1512},{"class":231,"line":358},[1513,1518],{"type":17,"tag":229,"props":1514,"children":1515},{"style":269},[1516],{"type":22,"value":1517},"                \"groupsToOverride\"",{"type":17,"tag":229,"props":1519,"children":1520},{"style":248},[1521],{"type":22,"value":1522},": groups,\n",{"type":17,"tag":229,"props":1524,"children":1525},{"class":231,"line":385},[1526],{"type":17,"tag":229,"props":1527,"children":1528},{"style":248},[1529],{"type":22,"value":1530},"            }\n",{"type":17,"tag":229,"props":1532,"children":1533},{"class":231,"line":579},[1534],{"type":17,"tag":229,"props":1535,"children":1536},{"style":248},[1537],{"type":22,"value":1538},"        }\n",{"type":17,"tag":229,"props":1540,"children":1541},{"class":231,"line":597},[1542],{"type":17,"tag":229,"props":1543,"children":1544},{"style":248},[1545],{"type":22,"value":653},{"type":17,"tag":229,"props":1547,"children":1548},{"class":231,"line":616},[1549,1553],{"type":17,"tag":229,"props":1550,"children":1551},{"style":263},[1552],{"type":22,"value":1295},{"type":17,"tag":229,"props":1554,"children":1555},{"style":248},[1556],{"type":22,"value":1557}," event\n",{"type":17,"tag":18,"props":1559,"children":1560},{},[1561,1563,1568],{"type":22,"value":1562},"After this, every token Cognito issues for an Entra-federated user contains a ",{"type":17,"tag":73,"props":1564,"children":1566},{"className":1565},[],[1567],{"type":22,"value":86},{"type":22,"value":1569}," claim with the user's Entra group IDs.",{"type":17,"tag":61,"props":1571,"children":1573},{"id":1572},"what-this-unlocks",[1574],{"type":22,"value":1575},"What this unlocks",{"type":17,"tag":18,"props":1577,"children":1578},{},[1579],{"type":22,"value":1580},"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.",{"type":17,"tag":18,"props":1582,"children":1583},{},[1584],{"type":22,"value":1585},"A few things you can do once the groups are in the token:",{"type":17,"tag":102,"props":1587,"children":1588},{},[1589,1606,1616,1626,1636,1646],{"type":17,"tag":106,"props":1590,"children":1591},{},[1592,1597,1599,1604],{"type":17,"tag":43,"props":1593,"children":1594},{},[1595],{"type":22,"value":1596},"API Gateway Lambda authorisers doing real RBAC.",{"type":22,"value":1598}," A custom authoriser decodes the JWT, reads ",{"type":17,"tag":73,"props":1600,"children":1602},{"className":1601},[],[1603],{"type":22,"value":86},{"type":22,"value":1605},", 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.",{"type":17,"tag":106,"props":1607,"children":1608},{},[1609,1614],{"type":17,"tag":43,"props":1610,"children":1611},{},[1612],{"type":22,"value":1613},"Frontend role-gating that matches the backend.",{"type":22,"value":1615}," 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.",{"type":17,"tag":106,"props":1617,"children":1618},{},[1619,1624],{"type":17,"tag":43,"props":1620,"children":1621},{},[1622],{"type":22,"value":1623},"Fine-grained AWS access via a Cognito Identity Pool.",{"type":22,"value":1625}," 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.",{"type":17,"tag":106,"props":1627,"children":1628},{},[1629,1634],{"type":17,"tag":43,"props":1630,"children":1631},{},[1632],{"type":22,"value":1633},"Audit logs that reference real org structure.",{"type":22,"value":1635}," 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.",{"type":17,"tag":106,"props":1637,"children":1638},{},[1639,1644],{"type":17,"tag":43,"props":1640,"children":1641},{},[1642],{"type":22,"value":1643},"Feature flags driven by group membership.",{"type":22,"value":1645}," 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.",{"type":17,"tag":106,"props":1647,"children":1648},{},[1649,1654],{"type":17,"tag":43,"props":1650,"children":1651},{},[1652],{"type":22,"value":1653},"Per-tenant data isolation.",{"type":22,"value":1655}," 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.",{"type":17,"tag":18,"props":1657,"children":1658},{},[1659,1661,1666],{"type":22,"value":1660},"The common thread is that the ",{"type":17,"tag":25,"props":1662,"children":1663},{},[1664],{"type":22,"value":1665},"integration",{"type":22,"value":1667}," problem gets solved exactly once, at the auth boundary, and everything downstream gets to treat groups as a normal Cognito concept.",{"type":17,"tag":61,"props":1669,"children":1671},{"id":1670},"a-caveat",[1672],{"type":22,"value":1673},"A caveat",{"type":17,"tag":18,"props":1675,"children":1676},{},[1677,1679,1684],{"type":22,"value":1678},"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 ",{"type":17,"tag":43,"props":1680,"children":1681},{},[1682],{"type":22,"value":1683},"not yet a tested end-to-end module",{"type":22,"value":1685},". Treat them as a reference for the moving parts rather than a copy-paste production-ready setup, and you will not be sad.",{"type":17,"tag":18,"props":1687,"children":1688},{},[1689],{"type":22,"value":1690},"If you ship something based on it, I would genuinely love to hear what hit you that wasn't covered here 🤝",{"type":17,"tag":1692,"props":1693,"children":1694},"style",{},[1695],{"type":22,"value":1696},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":8,"searchDepth":254,"depth":254,"links":1698},[1699,1700,1701,1708,1709],{"id":63,"depth":254,"text":66},{"id":135,"depth":254,"text":138},{"id":399,"depth":254,"text":402,"children":1702},[1703,1704,1706,1707],{"id":411,"depth":275,"text":414},{"id":672,"depth":275,"text":1705},"2. MetadataURL populates other fields you must also ignore",{"id":950,"depth":275,"text":953},{"id":1078,"depth":275,"text":1081},{"id":1572,"depth":254,"text":1575},{"id":1670,"depth":254,"text":1673},"markdown","content:articles:wiring-entra-id-into-aws-cognito.md","content","articles/wiring-entra-id-into-aws-cognito.md","articles/wiring-entra-id-into-aws-cognito","md",{"_path":1717,"_dir":7,"_draft":3,"_partial":3,"_locale":8,"title":1718,"description":1719,"published":1720,"slug":1721,"body":1722,"_type":1710,"_id":2074,"_source":1712,"_file":2075,"_stem":2076,"_extension":1715},"/articles/how-i-turned-a-puppy-cam-into-a-home-assistant-project","How I Turned a Puppy Cam into a Home Assistant Project","A small Home Assistant add-on that records my Tapo C230 to S3 using AWS IoT credentials and a very old Raspberry Pi.","2026/3/6","how-i-turned-a-puppy-cam-into-a-home-assistant-project",{"type":14,"children":1723,"toc":2067},[1724,1736,1748,1759,1764,1783,1789,1794,1812,1824,1840,1845,1857,1861,1866,1871,1942,1947,1953,1958,1979,1985,1996,2024,2036,2041,2047,2052,2057,2062],{"type":17,"tag":18,"props":1725,"children":1726},{},[1727,1729,1734],{"type":22,"value":1728},"My girlfriend and I recently got a puppy, which meant I had a very normal thought: ",{"type":17,"tag":25,"props":1730,"children":1731},{},[1732],{"type":22,"value":1733},"we should probably get an indoor camera so we can check whether she is sleeping peacefully or redecorating the living room with mud prints",{"type":22,"value":1735}," 🐶",{"type":17,"tag":18,"props":1737,"children":1738},{},[1739,1741,1746],{"type":22,"value":1740},"So I picked up a ",{"type":17,"tag":43,"props":1742,"children":1743},{},[1744],{"type":22,"value":1745},"Tapo C230",{"type":22,"value":1747}," from TP-Link. It does the job nicely, and for the original use case it was perfect. But after setting it up, I quickly realised it could double up as a simple security camera too.",{"type":17,"tag":18,"props":1749,"children":1750},{},[1751,1753,1758],{"type":22,"value":1752},"That was the moment I ran into the usual modern-hardware experience: ",{"type":17,"tag":25,"props":1754,"children":1755},{},[1756],{"type":22,"value":1757},"yes, you can store footage... if you pay us every month",{"type":22,"value":480},{"type":17,"tag":18,"props":1760,"children":1761},{},[1762],{"type":22,"value":1763},"Now, to be fair, that is a completely valid business model. It is also the kind of sentence that makes me immediately look around the house for spare hardware and start making questionable engineering decisions 😅",{"type":17,"tag":18,"props":1765,"children":1766},{},[1767,1769,1774,1776,1781],{"type":22,"value":1768},"In this case, the spare hardware was an old ",{"type":17,"tag":43,"props":1770,"children":1771},{},[1772],{"type":22,"value":1773},"Raspberry Pi 3",{"type":22,"value":1775}," already running ",{"type":17,"tag":43,"props":1777,"children":1778},{},[1779],{"type":22,"value":1780},"Home Assistant",{"type":22,"value":1782},". It is not exactly a powerhouse, and I do not have a huge smart home setup, so it felt like a good candidate for a small, focused solution instead of a heavyweight one.",{"type":17,"tag":61,"props":1784,"children":1786},{"id":1785},"the-idea",[1787],{"type":22,"value":1788},"The idea",{"type":17,"tag":18,"props":1790,"children":1791},{},[1792],{"type":22,"value":1793},"Rather than paying for cloud storage tied to the camera vendor, I decided to combine three things I already had:",{"type":17,"tag":102,"props":1795,"children":1796},{},[1797,1802,1807],{"type":17,"tag":106,"props":1798,"children":1799},{},[1800],{"type":22,"value":1801},"a local RTSP stream from the camera (nice of them to include)",{"type":17,"tag":106,"props":1803,"children":1804},{},[1805],{"type":22,"value":1806},"a Raspberry Pi running Home Assistant",{"type":17,"tag":106,"props":1808,"children":1809},{},[1810],{"type":22,"value":1811},"enough AWS knowledge to be dangerous",{"type":17,"tag":18,"props":1813,"children":1814},{},[1815,1817,1822],{"type":22,"value":1816},"The result was ",{"type":17,"tag":43,"props":1818,"children":1819},{},[1820],{"type":22,"value":1821},"RTSP to S3",{"type":22,"value":1823},", a custom Home Assistant add-on that captures the local camera stream, breaks it into MP4 segments, and uploads those segments to Amazon S3.",{"type":17,"tag":18,"props":1825,"children":1826},{},[1827,1829,1838],{"type":22,"value":1828},"The full project lives in the ",{"type":17,"tag":1830,"props":1831,"children":1835},"a",{"href":1832,"rel":1833},"https://github.com/georgi-ivanov19/Home-Assistant-Apps",[1834],"nofollow",[1836],{"type":22,"value":1837},"Home Assistant Apps repository",{"type":22,"value":1839},", where all the setup details, configuration options, and supporting files are documented properly.",{"type":17,"tag":18,"props":1841,"children":1842},{},[1843],{"type":22,"value":1844},"Once the camera setup was working, the whole thing naturally expanded into my personal Home Assistant apps repository, complete with automated version bumping and publishing, because why not 🤷‍♂️.",{"type":17,"tag":18,"props":1846,"children":1847},{},[1848,1850,1855],{"type":22,"value":1849},"If anyone miraculously wants to use it too, this is not a HACS thing - you can add the repository in Home Assistant via ",{"type":17,"tag":43,"props":1851,"children":1852},{},[1853],{"type":22,"value":1854},"Settings -> Apps -> Install App -> the three-dot menu in the top right -> Repositories",{"type":22,"value":1856}," and install it from there.",{"type":17,"tag":61,"props":1858,"children":1859},{"id":399},[1860],{"type":22,"value":402},{"type":17,"tag":18,"props":1862,"children":1863},{},[1864],{"type":22,"value":1865},"This is the part I enjoyed the most. It is not just \"record a file and throw it somewhere\". I wanted the setup to feel reasonably robust and not like something I would regret leaving running for weeks.",{"type":17,"tag":18,"props":1867,"children":1868},{},[1869],{"type":22,"value":1870},"So the add-on bakes in a few good practices:",{"type":17,"tag":102,"props":1872,"children":1873},{},[1874,1884,1894,1904,1914,1932],{"type":17,"tag":106,"props":1875,"children":1876},{},[1877,1882],{"type":17,"tag":43,"props":1878,"children":1879},{},[1880],{"type":22,"value":1881},"Temporary AWS credentials via AWS IoT certificates",{"type":22,"value":1883},", rather than long-lived access keys living on the device",{"type":17,"tag":106,"props":1885,"children":1886},{},[1887,1892],{"type":17,"tag":43,"props":1888,"children":1889},{},[1890],{"type":22,"value":1891},"S3 lifecycle policies",{"type":22,"value":1893}," so recordings expire automatically after 30 days and costs stay under control. Avoiding TP-Link fees only to pay AWS is somehow easier to justify 🤷‍♂️. I am pretty sure it will still be cheaper, but I need a full month of usage before I can say that confidently.",{"type":17,"tag":106,"props":1895,"children":1896},{},[1897,1902],{"type":17,"tag":43,"props":1898,"children":1899},{},[1900],{"type":22,"value":1901},"Home Assistant automations",{"type":22,"value":1903}," so recording only starts when nobody is home",{"type":17,"tag":106,"props":1905,"children":1906},{},[1907,1912],{"type":17,"tag":43,"props":1908,"children":1909},{},[1910],{"type":22,"value":1911},"Local storage limits",{"type":22,"value":1913}," so the Pi does not quietly fill its disk if uploads fall behind",{"type":17,"tag":106,"props":1915,"children":1916},{},[1917,1922,1924,1930],{"type":17,"tag":43,"props":1918,"children":1919},{},[1920],{"type":22,"value":1921},"Automatic restarts",{"type":22,"value":1923}," if ",{"type":17,"tag":73,"props":1925,"children":1927},{"className":1926},[],[1928],{"type":22,"value":1929},"ffmpeg",{"type":22,"value":1931}," exits unexpectedly",{"type":17,"tag":106,"props":1933,"children":1934},{},[1935,1940],{"type":17,"tag":43,"props":1936,"children":1937},{},[1938],{"type":22,"value":1939},"CloudFormation-based AWS setup",{"type":22,"value":1941}," for the infrastructure bits, which saves a lot of clicking around the console",{"type":17,"tag":18,"props":1943,"children":1944},{},[1945],{"type":22,"value":1946},"I also quite like the S3 object layout, which stores recordings by date and hour. It makes browsing recordings much less painful than dumping everything into one giant bucket prefix and hoping for the best.",{"type":17,"tag":61,"props":1948,"children":1950},{"id":1949},"surprisingly-fine-on-an-old-pi",[1951],{"type":22,"value":1952},"Surprisingly fine on an old Pi",{"type":17,"tag":18,"props":1954,"children":1955},{},[1956],{"type":22,"value":1957},"One of the nicest outcomes here is that it runs well on that old Raspberry Pi 3. I went into this expecting at least a little bit of drama, but for a simple single-camera setup it has been surprisingly happy.",{"type":17,"tag":18,"props":1959,"children":1960},{},[1961,1963,1968,1970,1977],{"type":22,"value":1962},"That said, I only recommend this approach for ",{"type":17,"tag":43,"props":1964,"children":1965},{},[1966],{"type":22,"value":1967},"simple setups like mine",{"type":22,"value":1969},". If you want something more advanced, with richer camera tooling and a more complete surveillance experience, I would point you towards ",{"type":17,"tag":1830,"props":1971,"children":1974},{"href":1972,"rel":1973},"https://frigate.video/",[1834],[1975],{"type":22,"value":1976},"Frigate",{"type":22,"value":1978},". It has features like motion detection and is a much better fit once you move beyond \"one camera, one lightweight add-on, one mildly obsessive owner\".",{"type":17,"tag":61,"props":1980,"children":1982},{"id":1981},"what-is-in-the-repo",[1983],{"type":22,"value":1984},"What is in the repo",{"type":17,"tag":18,"props":1986,"children":1987},{},[1988,1994],{"type":17,"tag":1830,"props":1989,"children":1991},{"href":1832,"rel":1990},[1834],[1992],{"type":22,"value":1993},"The repository",{"type":22,"value":1995}," has a lot more detail than I have room for here, including:",{"type":17,"tag":102,"props":1997,"children":1998},{},[1999,2004,2009,2014,2019],{"type":17,"tag":106,"props":2000,"children":2001},{},[2002],{"type":22,"value":2003},"the add-on structure for Home Assistant",{"type":17,"tag":106,"props":2005,"children":2006},{},[2007],{"type":22,"value":2008},"configuration options for RTSP, S3, IoT, buffering, and restart behaviour",{"type":17,"tag":106,"props":2010,"children":2011},{},[2012],{"type":22,"value":2013},"AWS setup steps",{"type":17,"tag":106,"props":2015,"children":2016},{},[2017],{"type":22,"value":2018},"an example Home Assistant automation for presence-based recording",{"type":17,"tag":106,"props":2020,"children":2021},{},[2022],{"type":22,"value":2023},"notes on supported cameras and current limitations",{"type":17,"tag":18,"props":2025,"children":2026},{},[2027,2029,2034],{"type":22,"value":2028},"There is also already a planned next step in the repo: ",{"type":17,"tag":43,"props":2030,"children":2031},{},[2032],{"type":22,"value":2033},"multi-camera support",{"type":22,"value":2035},". Right now the add-on is aimed at one camera per instance, which is fine for my setup, but expanding that cleanly would make it much more flexible.",{"type":17,"tag":18,"props":2037,"children":2038},{},[2039],{"type":22,"value":2040},"I would also like to improve the overall setup experience over time, polish the configuration flow a bit more, and keep reducing the number of manual steps needed to get from \"camera on the shelf\" to \"recordings in S3\".",{"type":17,"tag":61,"props":2042,"children":2044},{"id":2043},"final-thoughts",[2045],{"type":22,"value":2046},"Final thoughts",{"type":17,"tag":18,"props":2048,"children":2049},{},[2050],{"type":22,"value":2051},"I am very happy with how this turned out. It solves the exact problem I had, it leans on tools I already understand, and it is still running happily on hardware that most people would probably describe as \"retired 👴\".",{"type":17,"tag":18,"props":2053,"children":2054},{},[2055],{"type":22,"value":2056},"Could I have just bought a microSD card, stuck it in the camera, and called it a day?",{"type":17,"tag":18,"props":2058,"children":2059},{},[2060],{"type":22,"value":2061},"Absolutely.",{"type":17,"tag":18,"props":2063,"children":2064},{},[2065],{"type":22,"value":2066},"But where is the fun in that 😄",{"title":8,"searchDepth":254,"depth":254,"links":2068},[2069,2070,2071,2072,2073],{"id":1785,"depth":254,"text":1788},{"id":399,"depth":254,"text":402},{"id":1949,"depth":254,"text":1952},{"id":1981,"depth":254,"text":1984},{"id":2043,"depth":254,"text":2046},"content:articles:how-i-turned-a-puppy-cam-into-a-home-assistant-project.md","articles/how-i-turned-a-puppy-cam-into-a-home-assistant-project.md","articles/how-i-turned-a-puppy-cam-into-a-home-assistant-project",1790205415961]