DEV Community

Proving Zero Trust Actually Works: Entra ID + Cloudflare Access over Both OIDC and SAML

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 oncall 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:

Operator 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-identity with 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 = true makes Cloudflare call Microsoft Graph after the code exchange, which 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.

Comments

No comments yet. Start the discussion.