Entra auto entroll with JoinByURL

I am trying to setup entraID with auto entroll, using JoinByURL. I followed these docs here to configure the entra app, and these CLI command to create the JWT signer and Auth policy:

ziti edge create ext-jwt-signer entra-id \
  "https://login.microsoftonline.com/<TENANT_ID>/v2.0" \
  --jwks-endpoint "https://login.microsoftonline.com/<TENANT_ID>/discovery/v2.0/keys" \
  --audience "<CLIENT_ID>" \
  --claims-property oid \
  --external-id \
  --client-id "<CLIENT_ID>" \
  --external-auth-url "https://login.microsoftonline.com/<TENANT_ID>/v2.0" \
  --scopes "openid" \
  --scopes "profile" \
  --scopes "email" \
  --scopes "api://<CLIENT_ID>/<scope-name>" \
  --target-token ACCESS

ziti edge list ext-jwt-signers

ziti edge create auth-policy endpoint-entra \
  --primary-ext-jwt-allowed \
  --primary-ext-jwt-allowed-signers <EXT_JWT_SIGNER_ID>

ziti edge list auth-policies

ziti edge update ext-jwt-signer entra-id \
  --enroll-to-token=true \
  --enroll-to-cert=false \
  --enroll-auth-policy "<AUTH_POLICY_ID>" \
  --enroll-name-claims-selector "/preferred_username" \
  --enroll-attr-claims-selector "/roles"

When i test on my windows client (v2.11.2.5) using JoinByURL I get this error, despite this working once.

Step
Controller authentication
Error
The supplied token is not valid
Token claims
{
  "aud":"<CLIENT_ID>",
  "iss":"https://login.microsoftonline.com/<TENANT_ID>/v2.0",
  "iat":1782422415,
  "nbf":1782422415,
  "exp":1782426933,
  "aio":"AYQAe/8cA..........1aA/ErB...........0x7//PD.............iQX/84................3zxc=",
  "azp":"<CLIENT_ID>",
  "azpacr":"0",
  "name":"Jonah Carpenter",
  "oid":"<OID>",
  "preferred_username":"jcarpenter@domain.com",
  "rh":"1.ATg...............A.",
  "scp":"auth",
  "sub":"Ev...............s",
  "tid":"<TENANT_ID>",
  "uti":"T81_jDqMHkG1nzzbNb0xAA",
  "ver":"2.0",
"xms_ftd":"27..............1z"
}

What am I doing wrong?

Hey @jcarpenter-uam welcome to OpenZiti and the community!
I've been looking into this some.
In your original post I saw you were using ZDEW (Windows client) v2.9.7.1 - behavior changed for adding an identity by URL in v2.11.2.2.

With Windows client 2.11.2.5 you must be able to authenticate with the IdP before the identity is provisioned on the controller and added to the Windows client UI.

Could you recreate the problem and look at the controller logs to see if there is any indication as to why you're unable to authenticate?
Please delete the identity from the controller first, then recreate the problem and check controller logs.

I've actually gotten the auto-enroll to work using this:

ziti edge update ext-jwt-signer entra-id \
  --enroll-attr-claims-selector ""

But after successful auth, I am never able to connect to the network here are the router logs and the client logs:

ziti-edge-router-1  | [17071.443]   ERROR ziti/v2/router/xgress_edge.(*Acceptor).handleUngroupedUnderlay: {error=[invalid client certificate for api session]} failure accepting edge channel u{classic}->i{ziti-sdk-c[0]@DESKTOP-M4FG18P/XPaB} with underlay
ziti-edge-router-1  | [17071.443]   ERROR channel/v4.(*MultiListener).AcceptUnderlay: {underlayId=[XPaB] isGrouped=[false] error=[invalid client certificate for api session] underlayType=[]} failed to create channel
[2026-06-26T19:16:03.292Z]    INFO ziti-sdk:channel.c:948 on_channel_data() ch[0] channel disconnected [-4095/end of file]
[2026-06-26T19:16:03.292Z]    WARN ziti-sdk:channel.c:872 on_channel_close() ch[0] disconnected from edge router[ziti-router-01] -4095(end of file)
[2026-06-26T19:16:03.292Z]    INFO tunnel-cbs:ziti_tunnel_ctrl.c:1129 on_ziti_event() ztx[jcarpenter@<domain>.com] router ziti-router-01 disconnected
[2026-06-26T19:16:03.292Z]    INFO ziti-edge-tunnel:ziti-edge-tunnel.c:866 on_event() ztx[c:\windows\system32\config\systemprofile\appdata\roaming\netfoundry\ziti-enroll.<domain>.com_1280.json] router: ziti-router-01, status: disconnected, address: ziti-router-01.<domain>.com, version: v2.0.0|9bf62f39ec3b|2026-05-20T17:49:13Z|linux|amd64, 
[2026-06-26T19:16:03.292Z]    INFO ziti-sdk:channel.c:857 reconnect_channel() ch[0] reconnecting in 3488ms (attempt = 1)

But if i switch to --enroll-to-cert=true then everything works fine. I would although much prefer to use session based auth...

Hi @jcarpenter-uam, I'm having problems following along with exactly what's happening. I'm wondering if it's somehow an issue with entra somehow. Could you outline exactly the steps you are taking after setting up the signer too?

I'm specifically wondering what steps you are taking on the ZDEW and when/how you add the identity and when/how you 'forget' the identity and things like that. Also how did you install the overlay itself? I can see 2.0 from your last log (which is what i expected) but I'm wondering if you have more than one controller and how you've deployed the overlay. Anything like that which could help figure out what is going wrong here.

Also one thing I'd like to test is the enroll-attr-claims-selector and enroll-name-claims-selector set to blank/empty/unset to see if that makes any sort of difference?

I'll try to set my entra AD back up but I do worry that somehow your entra config is triggering this behavior, but I'll try to reproduce it.

If you want to completely copy my config:

I am using a your docker compose example here and env example here. My only other config change is adding a lets encrypt cert in the ziti-controller.yaml at web.identity.alt_server_certs. The entra app setup is documented here making note of the SPA note at the bottom.

These CLI commands to create the policy and ext-signer will place you directly in my shoes:

ziti edge create ext-jwt-signer entra \
  "https://login.microsoftonline.com/<TENANT_ID>/v2.0" \
  --jwks-endpoint "https://login.microsoftonline.com/<TENANT_ID>/discovery/v2.0/keys" \
  --audience "<CLIENT_ID>" \
  --claims-property oid \
  --external-id \
  --client-id "<CLIENT_ID>" \
  --external-auth-url "https://login.microsoftonline.com/<TENANT_ID>/v2.0" \
  --scopes "openid" \
  --scopes "profile" \
  --scopes "email" \
  --scopes "api://<CLIENT_ID>/<scope-name>" \
  --target-token ACCESS

ziti edge list ext-jwt-signers

ziti edge create auth-policy endpoint-entra \
  --primary-cert-allowed \
  --primary-ext-jwt-allowed \
  --primary-ext-jwt-allowed-signers <EXT_JWT_SIGNER_ID>

ziti edge list auth-policies

ziti edge update ext-jwt-signer entra \
  --enroll-to-token=true \
  --enroll-to-cert=true \
  --enroll-auth-policy "<AUTH_POLICY_ID>" \
  --enroll-name-claims-selector "/preferred_username" \
  --enroll-attr-claims-selector ""

Now with 0 tunnel identities in place, you go to the windows client v2.11.2.5 and use JoinByURL. This should prompt you with 2 options: User Session referencing --enroll-to-token and Device Certificate referencing --enroll-to-cert.

Device Certificate creates the identity based on your entra email, and correctly connects to the network no issues. Using the User Session option will correctly generate the identity, but leads to this error in the router logs when attempting to join the network although the windows client leads you to believe it is working

ziti-edge-router-1  | [17071.443]   ERROR ziti/v2/router/xgress_edge.(*Acceptor).handleUngroupedUnderlay: {error=[invalid client certificate for api session]} failure accepting edge channel u{classic}->i{ziti-sdk-c[0]@DESKTOP-M4FG18P/XPaB} with underlay
ziti-edge-router-1  | [17071.443]   ERROR channel/v4.(*MultiListener).AcceptUnderlay: {underlayId=[XPaB] isGrouped=[false] error=[invalid client certificate for api session] underlayType=[]} failed to create channel

Don't mean to dump everything, just wanted to cleanly explain my setup so it is easier to follow

Also one thing I'd like to test is the enroll-attr-claims-selector and enroll-name-claims-selector set to blank/empty/unset to see if that makes any sort of difference?

If i use this:

ziti edge update ext-jwt-signer entra \
  --enroll-name-claims-selector "" \
  --enroll-attr-claims-selector ""

The --enroll-name-claims-selector defaults to /sub which just gives the identity a less human readable name and leads to the same router error explained.

but I do worry that somehow your entra config is triggering this behavior

Honestly wouldn't surprise me, I know 0 about how the AD was setup and I try to avoid anything MS as much as possible. But I appreciate any information you can offer

Just to confirm, you have two distinct FQDNs, right? The LE certs cannot overlap the advertised address from the controller/router. I just want to clarify that you have a 'private' pki with a 'private' fqdn (the overlay network itself would use that) and then you have the LE 'public' PKI for things like the ZAC and the REST API.

Correct? I don't think I've seen that in your replies yet (if it's htere and I missed it, I'm sorry, I promise I looked for it) :slight_smile:

So i do have 2 sub-domains ziti-edge-ctrl and ziti-enroll. When joining via URL i am using the ziti-enroll.

identity:
  ca: "/persistent/pki/ziti-edge-controller-root-ca/certs/ziti-edge-controller-root-ca.cert"
  key: "/persistent/pki/ziti-edge-controller-intermediate/keys/ziti-edge-ctrl.<domain>.com-server.key"
  server_cert: "/persistent/pki/ziti-edge-controller-intermediate/certs/ziti-edge-ctrl.<domain>.com-server.chain.pem"
  cert: "/persistent/pki/ziti-edge-controller-intermediate/certs/ziti-edge-ctrl.<domain>.com-client.chain.pem"

  # Refer to ziti-enroll.<domain>.com
  alt_server_certs:
    - server_cert: "/persistent/tls/controller/fullchain.pem"
      server_key: "/persistent/tls/controller/privkey.pem"

I was having an issue testing due to TLS errors and the client being served with the self-signed cert for ziti-edge-ctrl, since there was no option to bypass TLS for testing i created the enroll

In light of your comment I did try using ziti-edge-ctrl for the join url by manually trusting the self-signed cert in windows, but i got the same error

Hi @jcarpenter-uam, there seem to be two things going on in this one post. Thanks for hanging in there with us.

We're going to try to reproduce the --enroll-attr-claims-selector "" problem you seem to be seeing. I personally haven't used that feature in my own testing but we're gonna add tests for it now and see if it works but in the meanwhile your second issue.... I've been able to reproduce that secondary problem you posted a few days ago, this one:

At the end of the day, this is looking like it's a problem with the way the docker bring up is happening. The openziti/quickstart image has always used different CAs for the control plane and the signing cert and this is not compatible with ephemeral certs but works fine with certificate-based identities as you noted.

The easiest way to move forward is to re-initialize the overlay which means you'll lose whatever you have done so far... if you append this to the bottom of your .env file and then down -v/up, you should be in a workable state. First, I'd recommend you export your config that you have so you can apply it after as well. If you run ziti ops export > /your/export.json you'll export your current configuration and after you down/up you can ziti ops import /your/export.json that file back into the new overlay:

# single-root PKI: all CAs share one root so certless OIDC works on 2.0+
ZITI_PKI_CTRL_ROOTCA_NAME=ziti-root-ca
ZITI_PKI_CTRL_EDGE_ROOTCA_NAME=ziti-root-ca
ZITI_PKI_SIGNER_ROOTCA_NAME=ziti-root-ca

Hopefully that would be an acceptable workaround for now? If I get more details or something else to share, I'll follow back up here.

The --enroll-attr-claims-selector "" is not something I am overly concerned about

As for the workaround it seems to be working fine on Windows and Andriod. I will continue watching for release notes referencing this issue, Thanks

Cool. One follow-up if you don't mind. I don't know why I didn't check this before but looking at the snippet of the token you sent me I don't see a "roles" field in the token. I see you ran an update and used --enroll-attr-claims-selector "/roles"... It might be we have a bug there for when the token doesn't exist in the JWT.

If you feel like it, and can confirm that the field was indeed NOT in the JWT that entra returned for you that'd be most helpful. And if it was there, I wonder if the format of the string matters to our parser (a separate possible bug).

If you don't mind helping out and providing the "roles" claim from the jwt sanitized (but keeping any odd-looking formatting like commas, colons, etc) that'd maybe be useful.

Appreciate it!

FWIW, I tested --enroll-attr-claims-selector with a few claims that can't be resolved (non-existent claims, and names containing spaces or commas) and they all produced the same "The supplied token is not valid" error.

I added --enroll-attr-claims-selector "/roles" back, here is the claim I get:

{
  "aud": "<CLIENT_ID>",
  "iss": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
  "iat": 1782842347,
  "nbf": 1782842347,
  "exp": 1782846494,
  "aio": "AW................TS",
  "azp": "<CLIENT_ID>",
  "azpacr": "0",
  "name": "Test Account",
  "oid": "66...........2a6",
  "preferred_username": "test-account@<domain>.com",
  "rh": "1.A..........A.",
  "scp": "auth",
  "sub": "ST............X8",
  "tid": "<TENANT_ID>",
  "uti": "Rm............AA",
  "ver": "2.0",
  "xms_ftd": "zKB...............1z"
}

Keep in mind my company does seem to use entra roles for either of my accounts, and I don't have perms to add any for testing. Either way I would expect an empty array in the absence of them.

I'm afraid I am not much use for a definite answer

Appreciate the token example. I think that proves out our theory as to why you had that original error. Something else for us to address I think (I'll bring it back to the team)

I don't see any roles in your example. In our testing (what @CLBRITTON2 did) we used DEX and that requires an additional scope. My LLM of choice adds this extra details (I have no way to verify the LLM take it for what it's worth -- if anything! :slight_smile: ) :

Entra works differently, there's no scope you can request for it. You enable it on the app registration itself, either by defining App Roles and assigning your user (or groups) to them, which puts a roles claim in the token, or by setting groupMembershipClaims in the app manifest, which puts a groups claim in. For this I'd lean toward App Roles: you get clean role values instead of group object GUIDs, and it avoids Entra's "groups overage" behavior where it drops the list and hands you a Microsoft Graph link once a user is in too many groups.

Anyway, I expect you can't adjust those settings. I think you've done enough and again, thanks so much.