Most Zero Trust write-ups stop at "user signs in, user gets access." That's not
where these integrations actually break. They break on group membership that
almost, but doesn't quite, satisfy a policy. They break on admin consent that
was never granted, silently, until the first real sign-in. They break on the
one country rule that was supposed to be an OR and was actually an AND.
I built a small, fully Terraform-managed demo to make those failure modes
visible instead of theoretical: Microsoft Entra ID driving Cloudflare Access
authorisation over both OIDC and SAML, against the same three users, with the
resulting identity provably verified at the origin, not just echoed.
The setup
Everything is provisioned by Terraform: the Entra app registrations, the demo
users and groups, the admin consent grant, both Cloudflare identity providers,
a dedicated tunnel with Cloudflare-managed ingress, DNS, and the Access
applications and policies themselves. One make apply and it's all live.
Entra is registered with Cloudflare twice. Once through the native azureAD
OIDC connector, where Cloudflare resolves group membership with a live
Microsoft Graph call at sign-in. Once as a generic SAML 2.0 app, where group
membership rides inside the signed assertion instead. Two applications point
at OIDC, one points at SAML, so the protocols sit side by side against
identical identities instead of being described in the abstract.
Three users, three apps, one deliberate trap
| user | Entra groups | echo-basic | echo-secure | echo-saml |
|---|---|---|---|---|
| alice | engineering, oncall, finance | yes | yes | yes |
| carol | engineering | yes | no | no |
| bob | contractors | no | no | no |
Alice and bob are the boring cases: pass everything, or fail everything. If
that's all you test, a single-group policy and a compound one look identical
from the outside. Carol is the point of the whole demo. She satisfies
echo-secure's include block because she's in engineering, and she's still
refused, because its require block also demands on-call membership and she
doesn't have it. That's the only way to actually show compound policy logic
rather than just assert it in a blog post.
That maps onto the three operators Cloudflare Access policies are built from,
and where hand-built policies most often go wrong:
| block | logic | in echo-secure |
|---|---|---|
| include | OR, match at least one | engineering or finance |
| require | AND, match every one | and on-call, and in-country |
| exclude | NOT, match none | and not a contractor |
There's also a deny policy at precedence 1 that short-circuits the chain
before the allow rule is ever evaluated, a purpose-justification prompt, a
30-minute session, and a path-scoped bypass policy that lets /health
through unauthenticated on a hostname that's otherwise fully protected.
Identity you can actually verify, not just see
The brief for the origin was simple: echo the request headers back. But raw
echo only proves a request arrived, it can't distinguish a request that
passed policy from one that reached the origin some other way, and the
Cf-Access-Jwt-Assertion header stays an opaque blob unless something
decodes it.
So the origin does three things instead of one:
- Verifies the JWT signature against the team's JWKS, with both issuer and audience pinned, audience being that specific application's AUD tag. A valid token minted for a different Access application in the same account gets rejected here. This is the check any real origin sitting behind Access should be doing.
- Calls
/cdn-cgi/access/get-identitywith the session cookie, but only after verification succeeds. An unproven session shouldn't cause the origin to make upstream calls on the user's behalf. - Resolves the group GUIDs both protocols return into readable names, and labels where each one came from: the identity endpoint for OIDC, the SAML assertion for SAML. That provenance label is the visible difference between the two protocols on the page.
A /raw route still gives the literal, unverified header dump for scripting.
Design decisions worth stealing
No circular dependency between Entra and Cloudflare. Both integrations
key off one URL, https://<team>.cloudflareaccess.com/cdn-cgi/access/callback,
which is derivable from the team name alone, before either side of the
integration exists. For OIDC it's the redirect URI. For generic SAML,
Cloudflare uses the same URL as both the Entity ID and the ACS endpoint.
Nothing has to exist before anything else, so the whole configuration applies
in a single Terraform pass.
Admin consent is configuration, not a portal click. support_groups = makes Cloudflare call Microsoft Graph after the code exchange, which
true
needs Directory.Read.All and GroupMember.Read.All with tenant-wide
consent. This is the step most often missed when an integration like this is
built by hand in the portal, and it fails at user sign-in, not at setup, so
the integration looks fine right up until someone actually tries to use it.
azuread_service_principal_delegated_permission_grant grants it
declaratively, followed by a 30 second time_sleep, because Entra's
directory is eventually consistent and an IdP created the instant the grant
returns can fail its first few sign-ins for no visible reason.
Group identity is always a GUID, never a display name, on both
protocols. Policies reference azuread_group.demo[...].object_id directly
rather than hand-copied GUIDs, and the origin gets a Terraform-generated
GUID to name map purely for display. Nothing security-relevant depends on
that map.
required_country is deliberately a single string, not a list. Every
entry in an Access require block is ANDed together. Two country rules
wouldn't permit either country, they'd make the policy unsatisfiable. That's
a genuinely easy trap to fall into by hand, which is why the Terraform
variable has a validation rule enforcing a single value.
The SAML email claim is name, not emailaddress. Entra sources the
emailaddress claim from the mail attribute, which is empty for cloud-only
users that have never been assigned a mailbox, exactly the users this project
creates. Pointing Cloudflare at it produces a session with a blank identity.
The name claim comes from userPrincipalName instead and is always
populated.
What this demo is not
It's a demonstration, not a production template, and it says so out loud:
Terraform state is local and unencrypted, the tunnel token lands in
.env.generated in plaintext, the three demo users share a password with no
forced rotation, there's no SCIM provisioning, and the SAML IdP doesn't sign
AuthnRequests. Every one of those is a deliberate trade for a demo that
stands up in one command instead of an afternoon, and docs/ARCHITECTURE.md
in the repo spells out what each one would need to become for anything
longer lived.
Why bother
Zero Trust vendors sell the diagram. What actually separates a policy that
works from one that only looks like it works is whether you tested the case
that's supposed to fail for a specific reason, not just any reason. Carol is
that case. If your own Access policies don't have a Carol, they haven't
really been tested yet.
Github Repository
The code is available at https://github.com/darkedges/cloudflare-zerotrust-entra
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.