Confused about user refresh of claims

What happened?

I’ve setup Pomerium core successfully with a couple of routes and custom identity provider (azure). This is in a lab-environment. One of my services (vaultwarden.pomerium.localhost) has a policy to match a group claim for a specific group-ID (f64d91fa-e526-441c-965e-8edb8b6f0a8b) and this policy works as expected after each Pomerium logout/login**.

When I remove the test user from that specific group and wait until background userdata refresh finishes, I still have access to that specific service.

Checking the verify service; I see that specific group-ID is still present in the pomerium identity jwt object.

What did you expect to happen?

I expected userdata refresh to retrieve an up-to-date (as of that moment) claims-set without the group from which the test user was removed.

The test user should be barred from accessing the protected route after background refresh of the claims when the user got removed from the group.

The verify endpoint should not advertise the group ‘f64d91fa-e526-441c-965e-8edb8b6f0a8b’ after the test user gets removed from it.

How’d it happen?

  1. Setup Pomerium core, see config below
  2. Visit https://vaultwarden.pomerium.localhost/admin, sign in, access denied
  3. [IDP] Add test user (myself: bert.proesmans) to group f64d91fa-e526-441c-965e-8edb8b6f0a8b
  4. Sign out of Pomerium
  5. Visit https://vaultwarden.pomerium.localhost/admin, sign in, access approved
  6. [IDP] Remove test user from group f64d91fa-e526-441c-965e-8edb8b6f0a8b
  7. Wait ~10 minutes for background refresh
  8. Visit https://vaultwarden.pomerium.localhost/admin, access still approved => UNEXPECTED

What’s your environment like?

  • Pomerium version (retrieve with pomerium --version): v0.32.2
  • Server Operating System/Architecture/Cloud: bare-metal, docker, no persisted storage

What’s your config.yaml?

address: '[::]:443'
log_level: debug
debug_address: '[::]:6060'
envoy_admin_address: '[::]:29091'

shared_secret: jMRfy/MXk4+ndku14PUU6+i6ofcQz7vyULbQLP7NpXQ=
cookie_secret: KDBIqKTq8aJ4OuU07np7qBtT6jX37I2H5DCczzExvP8=
signing_key_file: '/pomerium/ec_private.pem'

authenticate_service_url: 'https://authenticate.pomerium.localhost'

idp_provider: 'azure'
idp_provider_url: 'https://login.microsoftonline.com/<OMITTED>/v2.0'
idp_client_id: '<OMITTED>'
idp_client_secret: '<OMITTED>'

# NOTE; Container names resolve through Docker DNS to the corresponding management IP
routes:
  - from: https://verify.pomerium.localhost
    to: http://pomerium-verify:8000
    allow_any_authenticated_user: true
    pass_identity_headers: true
  - from: https://httpbin.pomerium.localhost
    to: http://httpbin:80
    allow_any_authenticated_user: true
    pass_identity_headers: true

  # WARN; Routes are matched in definition order! More specific routes must come before general routes!
  - from: https://vaultwarden.pomerium.localhost
    to: http://vaultwarden:80
    prefix: /admin
    policy:
      - allow:
          and:
            - domain:
                is: e-powerinternational.com
            - claim/groups: 
                # Name: TEST - Pomerium Vaultwarden admin
                has: 'f64d91fa-e526-441c-965e-8edb8b6f0a8b'
  - from: https://vaultwarden.pomerium.localhost
    to: http://vaultwarden:80
    policy:
      - allow:
          and:
            - domain:
                is: e-powerinternational.com

What did you see in the logs?

<snip>
{"level":"info","service":"identity_manager","user-id":"ec150b5a-8943-4842-b2d0-dad7a44737f2","time":"2026-03-18T15:14:00Z","message":"updating user info"}
{"level":"debug","service":"identity_manager","user-id":"ec150b5a-8943-4842-b2d0-dad7a44737f2","time":"2026-03-18T15:14:01Z","message":"updating user"}
{"level":"debug","record-type":"type.googleapis.com/user.User","record-id":"ec150b5a-8943-4842-b2d0-dad7a44737f2","time":"2026-03-18T15:14:01Z","message":"put"}
{"level":"debug","service":"identity_manager","record-type":"type.googleapis.com/user.User","record-id":"ec150b5a-8943-4842-b2d0-dad7a44737f2","record-version":6,"syncer-id":"identity_manager/users","syncer-type":"type.googleapis.com/user.User","time":"2026-03-18T15:14:01Z","message":"syncer got record"}
{"level":"debug","service":"identity_manager","user-id":"ec150b5a-8943-4842-b2d0-dad7a44737f2","time":"2026-03-18T15:14:01Z","message":"user updated"}
{"level":"debug","service":"envoy","name":"http2","time":"2026-03-18T15:14:01Z","message":"[Tags: \\\"ConnectionId\\\":\\\"5\\\"] Http2Visitor: remaining data payload: 1083, stream_id: 21, end_stream: false"}
<snip>

Additional context

I’ve tried tracing the update flow to pinpoint where exactly my expectations differ, but my Go-code knowledge is weak.

The list of ids in the groups claim never updates within a Pomerium session, I have to sign-out and back in again to get an accurate up-to-date view of group membership.

**BUT there is also some kind of dictionary-key-collapse happening. After I sign-in from another browser (causing a second session to be created for the same user) concurrently, now in both sessions the service access policy evaluation uses the current(/latest as of second sign-in) group claim values.
So the verify endpoint can show different group claim values in each session, while the actual service policy matcher is consistent with one, multiple, or none of sessions’ reported groups claim.

Is this the only group the user is a member of? I wonder if the claim is entirely missing once the user is removed and since we’re doing a merge it just keeps the claim as-is?

How is the claim populated in azure?

hey, thanks for taking a look!

Is this the only group the user is a member of?

No, I specifically designed my test so the user is member of 2 groups linked to the application. One (Pomerium Random) is just always present, the other (Pomerium Vaultwarden admin) controls access to /admin and membership is toggled on/off during manual testing.

How is the claim populated in azure?

As instructed by the manual ( Microsoft Entra ID (formerly Azure Active Directory) | Pomerium ), except;

  • I didn’t select all group types instead “Groups assigned to the application”. Groups are not emitted as role claims
  • The provided permissions to the application are; email/offline_access/openid/profile. I figured the listed permissions in the docs are required for directory sync, the data in my test setup is limited to the JWT objects themselves and the claims they hold (not Graph API data)

App registration→Token configuration

(i can only upload one screenshot per post as a new user)

App registration→Enterprise application→Users and groups

I’m not sure if you expected a ping-back, which you haven’t received automatically with my replies above.

I think the issue here may be that we are using object.union in Rego, which operates recursively, so the original group claims are being merged with the new group claims. We can change this behavior so that the original group claims is entirely replaced.

Unfortunately this doesn’t appear to be the case:


obj1 := { "groups": ["a","b","c"] }
obj2 := { "groups": ["x","y","z"] }
obj3 := object.union(obj1,obj2)
# obj3.groups => ["x","y","z"]

Lists aren’t merged, they are replaced, and the User object always takes precedence, so the latest group claims should be used. I will continue to investigate.

After investigating this further the groups claim is not returned by the User Info endpoint. It is only available in the id / access token. As a result updates to this field won’t be picked up on refresh. I’m not sure it’s possible to get this information in some other way, so I don’t know if we’d be able to update the Microsoft Entra identity provider to fix this.

An alternative solution would be to reduce the cookie timeout so that users log in more frequently since we will get the updated claim on login. Using Enterprise Pomerium would also fix this, since it uses a directory provider for this data and a group criterion in PPL.

After investigating this further the groups claim is not returned by the User Info endpoint. It is only available in the id / access token.

Understood.

From the documentation I gather it’s not possible to change the info returned via the oidc userinfo endpoint. Other authentication providers can, but we’re not using any other.

Using Enterprise Pomerium would also fix this, since it uses a directory provider for this data and a group criterion in PPL.

Do you have a sales contact that I can send an email to for followup? The “contact us” button on the website forces me to fill in a form, and then jumps into a meeting-booking SAAS that seems to not work (at the moment).

Sorry about that. You can contact sales@pomerium.com. Thanks.

Thanks for your time, looking into my report, and the contact address! Cheers