The previous post, “Adding Passkey Authentication to ADFS with PocketID”, covered the actual integration work: forking PocketID’s Go backend to expose a SAML IdP surface, wiring it into ADFS as a Claims Provider Trust, and walking through the configuration steps. Read that post and the work looks fairly contained. Configure the trust, map a few claims, done.
That post described what we built. This post describes what broke.
I’ll also add the note I added in the previous post: Claude was a significant part of getting through this. Not for writing Go code this time — that was the last post — but for the actual debugging. When you’re deep in ADFS error codes at 11pm with documentation that answers a different question than the one you’re asking, having something that can reason through the claim pipeline with you and surface non-obvious behaviors compresses the cycle dramatically. Several of the key insights in this post came out of those back-and-forth debugging sessions. I’ll call them out as we get to them. In all honesty, Claude and AI is becoming a very useful tool to have in your back pocket as you are troubleshooting (especially when you are a psuedo-expert in a topic, but don’t know all the ins and outs of a technology or stack).
Quick Recap: ADFS, RPTs, and SAML
If you haven’t read the previous post, here’s enough context to follow the debugging.
ADFS is Microsoft’s on-premises federation broker. It sits between your applications and your identity sources, brokering authentication and issuing tokens. Applications register with ADFS as Relying Party Trusts (RPTs) — they’re called relying parties because they rely on ADFS to tell them who the user is. In my setup, Grafana, GitLab, and AWS are all RPTs: when a user tries to log in, they get redirected to ADFS, which authenticates the user and issues a signed token back to the application.
On the inbound side, ADFS uses Claims Provider Trusts (CPTs) to define where it accepts identity assertions from. By default, Active Directory is the only CPT. The extension in the previous post added PocketID as a second CPT, meaning ADFS would accept a signed SAML assertion from PocketID as proof of identity for a user.
SAML assertions are the signed XML documents that travel between systems during this flow. When PocketID authenticates a user, it sends back a SAML assertion containing a NameID (the primary identity) and a set of attribute claims (whatever else the IdP wants to send). ADFS parses that assertion, extracts the claims, and then uses its own rules to decide what to do with them.
The troubleshooting in this post lives almost entirely in the gap between “ADFS received the assertion” and “the application got a valid token.” For full context on how the integration was built, see the previous post.
The first login attempt returned MSIS5007: The caller authorization failed for caller identity ... for relying party trust . What followed was eight distinct wrong turns, each one teaching something I probably should have already known about how ADFS actually works under the hood.
Before You Touch Anything: Enable Verbose Auditing
This is the only step I wish I had done first instead of second. ADFS produces useful diagnostic information, but it is off by default, which means the first hour of debugging happens in the dark.
1Set-AdfsProperties -AuditLevel Verbose
2auditpol /set /subcategory:"Application Generated" /success:enable /failure:enableOnce that is set, Security Event IDs 501, 1200, 1201, 1202, 1203, and 1204 appear in the Security event log. The following pulls the five most recent:
1Get-WinEvent -LogName Security | Where-Object { $_.Id -in (501, 1200, 1201, 1202, 1203) } |
2 Select-Object -First 5 | Format-List TimeCreated, Id, MessageA second trick worth knowing before you start: add a temporary debug claim to the Issuance Transform Rules for the relying party you are testing against, complete the login flow, and decode the resulting SAML response at samltool.com. This surfaces exactly what claims are present with what values and what issuers, which is far more informative than log messages alone.
1c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"]
2 => issue(Type = "http://temp/debug/windowsaccountname", Value = c.Value);One important caveat that comes up repeatedly: always append debug rules to existing IssuanceTransformRules rather than replacing them. Replacing them drops the NameID mapping rule, which causes the SP to reject the SAML response with InvalidNameIDPolicy.
1$existingRules = (Get-AdfsRelyingPartyTrust -Name "Gitlab").IssuanceTransformRules
2$debugRule = 'c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid"] => issue(Type = "http://temp/debug/groupsid", Value = c.Value);'
3Set-AdfsRelyingPartyTrust -TargetName "Gitlab" -IssuanceTransformRules ($existingRules + $debugRule)You can also inspect the current Claims Provider Trust state directly:
1Get-AdfsClaimsProviderTrust -Name "PocketID" | Select-Object Name, AnchorClaimType, AcceptanceTransformRules | Format-ListWith auditing on and a debug claim path available, most of what follows would have resolved in half the time. I learned this the hard way. With auditing actually enabled, the first thing I did was look at what ADFS was seeing from PocketID — and that’s where the first real problem showed up.
The NameID Was Wrong the Whole Time
The verbose audit logs showed the UserId field for PocketID authentications as a GUID, something like a3f8c2d1-7b4e-4a12-9f0c-2e5d8a1b3c6f. That is not a domain account. That is PocketID’s internal user identifier, which is what PocketID sends as the SAML NameID by default.
ADFS uses the NameID to construct the user identity it will work with for the rest of the authentication pipeline. When that identity is a GUID, ADFS has no way to link it to anything in Active Directory. The claims provider shown in the audit logs was LOCAL AUTHORITY rather than AD AUTHORITY.
These two labels matter more than they might seem. AD AUTHORITY means the identity was verified against Active Directory — either because the user authenticated directly via Kerberos, or because a claim was produced by querying the AD attribute store. Claims carrying AD AUTHORITY as their issuer are trusted by downstream rules as genuinely AD-backed. LOCAL AUTHORITY means the claim was produced inside the ADFS pipeline without any AD verification behind it. A claim that arrived from an external IdP and was passed through gets LOCAL AUTHORITY. A claim you construct manually via a transform rule also gets LOCAL AUTHORITY. Most existing relying party rules in a standard ADFS setup check Issuer == "AD AUTHORITY" before doing anything meaningful with a claim, which means a LOCAL AUTHORITY claim will silently pass through those rules without triggering them.
When PocketID sent a GUID as the NameID, ADFS couldn’t correlate it to any AD account. The whole authentication was running as LOCAL AUTHORITY — a floating, unverified identity that had no connection to the directory, and no downstream rule that checked for AD-backed identity would ever fire for it.
The fix was to change PocketID to send the user’s UPN as the NameID using the emailAddress format. That change is covered in the previous post. After applying it, the audit logs immediately showed UserId as akrauza@ad.krauza.cloud and the claims provider shifted to AD AUTHORITY. That was the first genuinely encouraging sign in a long debugging session.
With the NameID corrected, I now had a proper UPN coming into the pipeline. The natural next question was whether ADFS could use that UPN to link the identity back to Active Directory, which led me to try something that seemed logical at the time.
The OrganizationalAccountSuffix Dead End
I set OrganizationalAccountSuffix on the CPT, hoping it might signal to ADFS that accounts arriving from PocketID should be treated as local domain accounts.
1Set-AdfsClaimsProviderTrust -TargetName "PocketID" -OrganizationalAccountSuffix "ad.krauza.cloud"This produced a second login prompt during the authentication flow. The OrganizationalAccountSuffix parameter is designed for ADFS-initiated Home Realm Discovery flows, specifically to help ADFS route users to the correct claims provider when it needs to decide where to send them. It is not relevant to SP-initiated SAML flows, which is what every application in this setup uses. The extra prompt was ADFS surfacing an HRD selection page it now thought was necessary. I reverted that immediately.
So the GUID NameID problem was solved, but authentication was still failing. The logs showed claims flowing. The issue had to be somewhere in the authorization layer, which meant it was time to understand how that layer actually works.
The Wrong Door First
My first assumption was that MSIS5007: The caller authorization failed for caller identity ... for relying party trust was a claim mapping problem, so I went straight to the Issuance Transform Rules on the Claims Provider Trust. I added a groupsid claim rule to the acceptance rules so that downstream relying parties could check group membership.
That did nothing. The reason it did nothing is that LDAP queries embedded in CPT acceptance rules do not execute reliably in the ADFS processing pipeline. They appear syntactically valid and the GUI accepts them without complaint, but the query against the Active Directory store does not consistently fire at that stage. I spent more time than I should have on this before accepting that the problem was elsewhere.
The actual root cause of MSIS5007 was simpler and more fundamental: Issuance Authorization Rules, not Transform Rules. The relying party applications had IssuanceAuthorizationRules either missing or misconfigured for federated identities. But understanding why the authorization layer was broken required understanding how the authorization layer actually works, and that sent me somewhere I didn’t expect.
The Architecture Problem Nobody Warned Me About
With verbose auditing enabled, I could see that the AccessControlPolicyParameters for several relying parties was empty even though a policy was visibly set in the GUI. I fixed the parameter binding. The authentication still failed.
This is the single most important thing I learned through this whole process, and it is not prominently documented anywhere: Access Control Policies in ADFS resolve group membership through the Windows security token, via Kerberos. They do not evaluate claims. A policy rule that says “permit users in group X” is checking whether the user’s Windows token contains a group SID, not whether any claim with a group SID value is present in the pipeline.
A federated identity arriving from PocketID has no Windows token. It has claims. No matter how correct and complete those claims are, Access Control Policies will never be satisfied by a federated identity. The policy engine simply cannot see group membership for external users.
This is not a configuration problem. It is an architectural constraint. The fix is not in the policy; the fix is in abandoning policies entirely for relying parties that need to accept federated identities, and using IssuanceAuthorizationRules instead. I didn’t fully accept this until every test I ran confirmed it and the audit logs made it unambiguous. Before I could get there, though, I had to work through several more issues in the claim pipeline itself.
MSIS9642 and the AnchorClaimType Gap
Testing the Grafana OIDC flow specifically, it failed with MSIS9642: The request cannot be completed because an id token is required but the server was unable to construct an id token for the current user..
MSIS9642 means ADFS could not build an ID token because it did not know which claim to use as the subject identifier. For external CPTs, ADFS needs an explicit AnchorClaimType to know which incoming claim represents the stable user identity it should anchor the token to. This is not set by default on a newly configured CPT, which means the first time you test an OIDC flow against a new external claims provider, it fails.
1Set-AdfsClaimsProviderTrust -TargetName "PocketID" `
2 -AnchorClaimType "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"Setting this to the UPN claim resolved MSIS9642 immediately. The Grafana OIDC flow proceeded past that error. It then hit the next one.
The Issuer Problem
Several downstream relying parties had Issuance Transform Rules checking for the windowsaccountname claim with the condition Issuer == "AD AUTHORITY". This pattern is common for ADFS rules that need to confirm the identity actually came from Active Directory rather than being synthesized somewhere in the pipeline.
The incoming UPN from PocketID has LOCAL AUTHORITY as its issuer, because it arrived from an external system. A windowsaccountname claim manually constructed from that UPN via a regex transform also carries LOCAL AUTHORITY. Those downstream rules were never firing, because the issuer check failed.
The instinct to re-issue the claim with a different issuer does not work reliably. I tested that approach enough to stop trusting it.
The correct approach came from understanding something non-obvious about how ADFS handles Active Directory store queries. When you specify a DOMAIN\username binding in an AD store query, the domain portion (KRAUZA\) is only used as a DC locator. ADFS uses it to find a domain controller to query against. The username portion is ignored entirely. The actual lookup is driven by the query string itself.
This means you can write KRAUZA\ignored as the binding and ADFS will correctly route the query to the right domain controller, use the userPrincipalName={0} query to find the right user, and return the sAMAccountName. Critically: claims that come back from an Active Directory store query get AD AUTHORITY as their issuer natively. You do not need to construct it, set it, or re-issue anything. The store query does it for you.
The working CPT Acceptance Transform Rules:
1$transformrules = New-AdfsClaimRuleSet -ClaimRule @'
2
3@RuleName = "Pass through UPN"
4c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"]
5 => issue(claim = c);
6
7@RuleName = "Issue Windows Account Name from AD"
8c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", Value =~ "^(?i)(.+)@ad\.krauza\.cloud$"]
9 => issue(store = "Active Directory", types = ("http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"),
10 query = "userPrincipalName={0};sAMAccountName;KRAUZA\ignored", param = c.Value);
11
12'@
13
14Set-AdfsClaimsProviderTrust `
15 -TargetName "PocketID" `
16 -AcceptanceTransformRules $transformrules.ClaimRulesStringAfter this, the debug claim confirmed that windowsaccountname was present as KRAUZA\akrauza with AD AUTHORITY as the issuer. The downstream rules started firing. The claim pipeline was finally clean, which meant the authorization layer failure was no longer hiding behind anything else.
A Pre-existing Bug This Exercise Surfaced
While tracing why group membership claims were still not populating correctly for Grafana, I found a bug in the Issuance Transform Rules that had probably always been there. It just had never mattered before because domain accounts were satisfying authorization through a different path.
The rule was using a tokenGroups query against the Active Directory store with a regex condition, something like Value =~ "^Grafana.+", intended to filter for groups whose names matched the Grafana prefix. The tokenGroups attribute returns group SIDs, not group name strings. The value coming back looked like S-1-5-21-..., and the regex ^Grafana.+ will never match a SID. The rule had been silently broken the entire time.
The fix was to move away from name-based regex matching on tokenGroups entirely and switch to explicit value-based matching, which is what the next section covers.
Replacing Access Control Policies with IssuanceAuthorizationRules
With windowsaccountname correct and AD AUTHORITY set, I ran one final test expecting it all to work. The Access Control Policy still failed. Going back to the architectural constraint from earlier: the policy engine cannot evaluate group membership for federated identities regardless of how correct the claims are. The claims and the policy are looking at different things, and there is no way to bridge that gap from within the policy configuration.
The fix is to clear the AccessControlPolicyName on each affected application and replace it with IssuanceAuthorizationRules that check group membership claims directly.
One PowerShell gotcha worth knowing upfront: you cannot clear an Access Control Policy and set IssuanceAuthorizationRules in the same cmdlet call. ADFS returns PS0238 (Web API Applications) or PS0236 (Relying Party Trusts) if you try. The Access Control Policy must be cleared in a separate call first. A second gotcha: backtick escape sequences in claim rule strings are unreliable when rules are saved to disk and reloaded. Use [Environment]::NewLine for newlines and "" for embedded quotes instead.
Here is the full production script I ended up with after working through all of these issues:
1# =============================================================================
2# Set-AdfsIssuanceAuthorizationRules.ps1
3# =============================================================================
4
5$webApis = @(
6 @{ Name = "Grafana - Web API"; Groups = @("Grafana Users") },
7 @{ Name = "Concourse - Web API"; Groups = @("Concourse Users") },
8 @{ Name = "OpenWebUI - Web API"; Groups = @("OpenWebUI_Admin") },
9 @{ Name = "Proxmox Lab - Web API"; Groups = @("Proxmox Admin") }
10)
11
12$relyingParties = @(
13 @{ Name = "Gitlab"; Groups = @("Gitlab Users") },
14 @{ Name = "Guacamole"; Groups = @("GUACAMOLE-Admin") },
15 @{ Name = "Splunk"; Groups = @("SPLUNK-Admin") }
16)
17
18function Build-AuthRules {
19 param([string[]]$Groups)
20 $rules = @()
21 foreach ($g in $Groups) {
22 $group = Get-ADGroup -Identity $g -ErrorAction SilentlyContinue
23 if (-not $group) {
24 Write-Warning " Group '$g' not found in AD - skipping"
25 continue
26 }
27 $claimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid"
28 $permitType = "http://schemas.microsoft.com/authorization/claims/permit"
29 $rules += "c:[Type == ""$claimType"", Value == ""$($group.Name)""] => issue(Type = ""$permitType"", Value = ""true"");"
30 }
31 return $rules -join [Environment]::NewLine
32}
33
34function Set-WebApiAuthRules {
35 param($Apps)
36 Write-Host ""
37 Write-Host "=== Processing Web API Applications ===" -ForegroundColor Cyan
38 foreach ($app in $Apps) {
39 Write-Host ""
40 Write-Host "-> $($app.Name)" -ForegroundColor Yellow
41 $current = Get-AdfsWebApiApplication -Name $app.Name -ErrorAction SilentlyContinue
42 if (-not $current) { Write-Warning " Web API '$($app.Name)' not found - skipping"; continue }
43 if ($current.AccessControlPolicyName) {
44 Write-Host " Clearing Access Control Policy '$($current.AccessControlPolicyName)'..."
45 Set-AdfsWebApiApplication -TargetName $app.Name -AccessControlPolicyName "" -AccessControlPolicyParameters @()
46 }
47 $rules = Build-AuthRules -Groups $app.Groups
48 if (-not $rules) { Write-Warning " No valid rules generated - skipping"; continue }
49 Set-AdfsWebApiApplication -TargetName $app.Name -IssuanceAuthorizationRules $rules
50 $updated = Get-AdfsWebApiApplication -Name $app.Name
51 if ($updated.IssuanceAuthorizationRules) {
52 Write-Host " OK - Updated successfully with groups: $($app.Groups -join ', ')" -ForegroundColor Green
53 } else {
54 Write-Warning " FAILED - Verify manually"
55 }
56 }
57}
58
59function Set-RelyingPartyAuthRules {
60 param($RPs)
61 Write-Host ""
62 Write-Host "=== Processing Relying Party Trusts ===" -ForegroundColor Cyan
63 foreach ($rp in $RPs) {
64 Write-Host ""
65 Write-Host "-> $($rp.Name)" -ForegroundColor Yellow
66 $current = Get-AdfsRelyingPartyTrust -Name $rp.Name -ErrorAction SilentlyContinue
67 if (-not $current) { Write-Warning " Relying Party '$($rp.Name)' not found - skipping"; continue }
68 if ($current.AccessControlPolicyName) {
69 Write-Host " Clearing Access Control Policy '$($current.AccessControlPolicyName)'..."
70 Set-AdfsRelyingPartyTrust -TargetName $rp.Name -AccessControlPolicyName ""
71 }
72 $rules = Build-AuthRules -Groups $rp.Groups
73 if (-not $rules) { Write-Warning " No valid rules generated - skipping"; continue }
74 Set-AdfsRelyingPartyTrust -TargetName $rp.Name -IssuanceAuthorizationRules $rules
75 $updated = Get-AdfsRelyingPartyTrust -Name $rp.Name
76 if ($updated.IssuanceAuthorizationRules) {
77 Write-Host " OK - Updated successfully with groups: $($rp.Groups -join ', ')" -ForegroundColor Green
78 } else {
79 Write-Warning " FAILED - Verify manually"
80 }
81 }
82}
83
84Set-WebApiAuthRules -Apps $webApis
85Set-RelyingPartyAuthRules -RPs $relyingParties
86Write-Host ""
87Write-Host "=== Done ===" -ForegroundColor CyanA Ninth Layer I Didn’t See Coming
I said eight layers. I miscounted.
After running the script and watching everything report green, I tested GitLab. It failed. Grafana worked, GitLab did not, and the only difference between them was which group the user needed to be in. Both groups existed in AD. The user was a member of both. The rules looked identical in structure.
The two-step diagnostic approach is the right one here. First, confirm that claims are reaching the RP at all by temporarily permitting anyone carrying any groupsid claim:
1$debugRule = "c:[Type == ""http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid""] => issue(Type = ""http://schemas.microsoft.com/authorization/claims/permit"", Value = ""true"");"
2Set-AdfsRelyingPartyTrust -TargetName "Gitlab" -IssuanceAuthorizationRules $debugRuleGitLab immediately started working with that rule. So the claims were flowing correctly. The specific group value just wasn’t matching what the authorization rule expected. That narrowed it down to a value format mismatch, which led to the second step: dumping all groupsid values into the SAML response to see exactly what was there.
1$existingRules = (Get-AdfsRelyingPartyTrust -Name "Gitlab").IssuanceTransformRules
2$debugRule = "c:[Type == ""http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid""] => issue(Type = ""http://temp/debug/groupsid"", Value = c.Value);"
3Set-AdfsRelyingPartyTrust -TargetName "Gitlab" -IssuanceTransformRules ($existingRules + $debugRule)Decoded the SAML response at samltool.com and looked at the http://temp/debug/groupsid attributes. They contained Gitlab Users. Not S-1-5-21-.... Group name strings.
The claim type is literally called groupsid, which strongly implies SID values. Everything I had read about tokenGroups confirmed it returns raw SIDs. But when tokenGroups is queried through the ADFS AD attribute store specifically, ADFS automatically resolves the SIDs to group name strings before putting them in the claim. This is different from querying tokenGroups via ADSI or PowerShell directly, where you get the raw SIDs back.
The fix was to update Build-AuthRules to match on $group.Name instead of $group.SID.Value. The production script above already incorporates this correction. The lesson is not the specific fix; it’s the diagnostic method. Never assume you know what the actual claim value looks like. Add a debug rule and check before writing authorization rules.
Meanwhile, Back in PocketID
All of the above was ADFS-side work. While that debugging was in progress, there were also several issues on the PocketID side that needed fixing before the end-to-end flow was reliable. These weren’t a separate adventure after ADFS was sorted; they were surfacing in parallel, and a few of them were blocking the kind of clean end-to-end tests that would have confirmed ADFS progress earlier.
The most confusing was a redirect loop. When a user was already authenticated in PocketID and ADFS initiated SSO, the browser would land at /settings instead of completing the SAML flow. The SvelteKit layout guard was unconditionally sending authenticated users away from /login to /settings, without checking whether the ?redirect= parameter was pointing at a SAML endpoint. The fix was to check whether the redirect param starts with /saml/ and pass it through instead of redirecting away.
A second loop emerged when ADFS navigated back to /saml/callback with no pending session in the cookie. This caused the callback handler to redirect to /login?redirect=/saml/callback, which the layout guard then forwarded back to /saml/callback, which sent it back to login, indefinitely. The fix was to detect the “authenticated but no pending session” state in the callback handler and redirect to /login?error=session_expired instead, and to suppress the layout guard’s redirect-to-settings when a ?error= parameter is present.
The other PocketID issue was a CSP form-action violation. The autosubmit form that POSTs the SAMLResponse to ADFS was being blocked by some browsers because ADFS 302-redirects the POST to the downstream application’s callback URL, and that URL was not in the form-action directive. The violation first appeared on iOS, diagnosed using Safari’s remote debugger:
- On iPhone: Settings > Safari > Advanced > Web Inspector > On
- Connect iPhone to Mac via USB
- On Mac: Safari > Develop > [iPhone name] > [the open tab]
- Console tab shows the CSP violation; Network tab shows exactly which redirect destination was blocked
The same issue appeared on Firefox for the same reason: Firefox enforces form-action on redirect destinations, not just the initial POST target. Chrome does not. So the bug was silent during development on Chrome and only surfaced on Firefox and iOS Safari. The fix was to widen form-action to * on the autosubmit page. The downstream callback URL cannot be statically predicted since it depends on which application initiated the ADFS flow.
Things I Wish I Had Known Going In
Nine lessons, each one attached to the specific failure that produced it. The last one almost didn’t make the list.
1. Access Control Policies cannot authorize federated identities. The policy engine checks the Windows security token, not claims. A federated user from PocketID has no Windows token, so no Access Control Policy will ever permit them regardless of what claims they carry. Any relying party that needs to accept federated identities needs IssuanceAuthorizationRules with explicit group checks, not a policy.
2. The SAML NameID is how ADFS builds the user’s identity downstream. If PocketID sends its internal GUID as the NameID (which is the default), ADFS builds an identity around that GUID and has no way to connect it to Active Directory. The NameID must be the user’s UPN in emailAddress format for any AD linkage to work.
3. External CPTs need AnchorClaimType set for OIDC flows. ADFS cannot build an ID token without knowing which claim to use as the subject. This is not set by default on new CPTs. MSIS9642 means this is missing.
4. LDAP queries in CPT acceptance rules do not execute reliably. Use the Active Directory store query approach with userPrincipalName={0} in the acceptance transform rules instead. That approach is well-defined in the pipeline and consistently produces the right claims.
5. In AD store queries, DOMAIN\username is only a DC locator; the username is ignored. KRAUZA\ignored is a perfectly valid binding string. ADFS uses KRAUZA\ to find a domain controller and then runs the query. Writing it this way makes it explicit that the username portion is meaningless.
6. Claims from AD store queries get AD AUTHORITY as their issuer natively. There is no need to re-issue a claim to change its issuer. If a downstream rule checks Issuer == "AD AUTHORITY", the correct fix is to source that claim from an AD store query, not to construct the issuer manually.
7. tokenGroups returns SIDs, not group name strings. Any regex written to match group names against tokenGroups results will never match anything.
8. groupsid claim values from the ADFS AD attribute store are group name strings, not SIDs. Despite the claim type name, the attribute store resolves them automatically. Never assume you know what the value format looks like. Add a debug rule and decode the SAML response at samltool.com before writing authorization rules.
9. PowerShell claim rule strings have two common failure modes. You cannot clear an Access Control Policy and set IssuanceAuthorizationRules in the same call (PS0238 for Web APIs, PS0236 for Relying Party Trusts). And backtick escapes in claim rule strings get mangled when rules are saved to disk and reloaded. Use "" for embedded quotes and [Environment]::NewLine for newlines.
What We Were Reading, and Where It Ran Out
Two external resources were genuinely useful during this process, and both of them also had a ceiling that required going beyond them.
The first was a blog post by Liam Cleary on creating claims rules in ADFS using PowerShell. It confirmed the correct PowerShell syntax for New-AdfsClaimRuleSet and showed the pattern for querying the AD attribute store directly from within a claim rule — including the key point that claims returned from an AD store query carry AD AUTHORITY as their issuer natively. The .ClaimRulesString pattern used in the acceptance transform rules came directly from there. Where it ran out: the article uses mail as the lookup key and a real service account in the credential parameter. We needed userPrincipalName as the filter, and the article doesn’t mention that the username in the credential parameter is ignored entirely. It also doesn’t cover AnchorClaimType for OIDC flows or the Access Control Policy limitation for federated identities.
The second was a ServerFault answer on combining claims from provider trusts and AD. This one contained the most important single insight in the whole debugging process: that the DOMAIN\username in an ADFS LDAP query is only used to locate the domain controller, and the username is completely ignored. That’s where KRAUZA\ignored came from. The answer also showed the two-rule compatibility pattern — one rule for federated users, one for direct AD users — which explained why existing RP rules didn’t break for standard logins once PocketID was added. Where it ran out: the answer applied the enrichment rule at the Relying Party level, and used emailaddress as the lookup key. We needed the enrichment at the CPT acceptance level so it applied globally, and PocketID sends a UPN, not an email claim. The answer also doesn’t address AnchorClaimType or the fundamental Access Control Policy constraint.
Both resources answered real questions and pointed in the right direction. Neither of them connected all the dots on their own. That gap is most of what this post is trying to fill.
On That Note
Nine wrong turns for one working passkey login, it turns out. Most of them were caused by the same underlying dynamic: ADFS has several parallel mechanisms for authorization and identity resolution, and they do not all behave the same way when a federated identity is involved. The documentation does not always make clear which mechanism is active in which context, so the only reliable approach is the one that works anyway: enable auditing, read the logs, form a hypothesis, test it, and eliminate it cleanly before moving to the next one.
As mentioned earlier, Claude also a decent footnote here. The KRAUZA\ignored insight, the groupsid-names-not-SIDs discovery, the two-step debug pattern for authorization failures — those came out of active back-and-forth sessions, not documentation. I’d describe myself as a solid security engineer, but not an expert in ADFS internals or implementation: I know enough to be dangerous, but ADFS has enough dark corners that I’d have spent days longer on this working through it alone. Having something that could hold the full context of the problem, reason through the claim pipeline, and propose the next hypothesis to test made the difference between a few night project after work, and a multi-week slog.
The configuration is stable now. Passkey authentication through PocketID reaches all three applications, group membership gates access correctly, and the claim pipeline is clean enough that I actually understand what each rule is doing. That last part is the part that will matter the next time something breaks.