Cloudflare Access JWT validation + RBAC for Python backends
Project description
cc-auth
Python library for validating Cloudflare Access JWTs and enforcing role-based access control (RBAC) in backend services.
Every service behind a Cloudflare Access Application receives a signed JWT on every request (CF-Access-Jwt-Assertion header / CF_Authorization cookie). This library:
- Validates the JWT signature against Cloudflare's public keys (JWKS)
- Extracts identity — email, name, Entra ID group memberships
- Maps Entra ID groups → application roles
- Provides FastAPI middleware and dependencies so routes declare their role requirements in one line
Documentation
| Document | Description |
|---|---|
| Azure + Cloudflare group sources | Deep dive into the two mechanisms that supply group membership (Microsoft Graph vs ID Token claim), why both exist, and which one cc-auth relies on |
| ADR-001 — Group claims strategy | Architecture Decision Record: why Source A (Support Groups) is the canonical group source, and the trade-offs accepted |
Architecture
Microsoft Entra ID
│
│ User authenticates (OIDC)
▼
Cloudflare Zero Trust (Access Application)
│
│ Issues signed JWT containing:
│ { "email": "user@cc.com",
│ "groups": ["group-id-1", "group-id-2"],
│ "iss": "https://<team-domain>" }
│
│ JWT travels as header + cookie on every request
▼
Your FastAPI backend ◄──── cc-auth validates + maps groups → roles
│
│ Route reads User.roles for RBAC decisions
▼
Business logic
Important: the backend must always validate the JWT itself. Cloudflare validates at the edge, but if your origin IP is ever reachable directly (bypassing Cloudflare), only your backend's validation stands.
Installation
pip install cc-auth
# or with FastAPI middleware helpers:
pip install "cc-auth[fastapi]"
Pin a specific version for reproducible production builds:
cc-auth[fastapi]==0.1.0
Quickstart (FastAPI)
1. Enable group claims (one-time, per Zero Trust account)
In Zero Trust → Settings → Authentication → your Azure AD identity provider → Edit:
- Enable "Support groups"
This makes Cloudflare include the user's Entra ID group Object IDs in every JWT.
2. Add the library to your app
# main.py
from fastapi import FastAPI
from cc_auth import CloudflareAuth
auth = CloudflareAuth.from_env()
app = FastAPI()
app.add_middleware(auth.middleware(exclude_paths=["/health"]))
No configuration is required to get started. Add CF_ACCESS_GROUP_MAPPING when you
need role-based access control (see Environment variables).
3. Use in routes
Most routes need nothing extra — the middleware already blocks unauthenticated
requests. Only add Depends(...) when you need the user object or role checks inside
the handler:
from cc_auth import User
# Protected automatically by middleware — no Depends needed
@app.get("/items")
async def list_items():
return [...]
# Need to know who the user is
@app.get("/me")
async def me(user: User = Depends(auth.current_user)):
return {"email": user.email, "roles": user.roles}
# Restrict to admins only
@app.delete("/items/{id}")
async def delete_item(id: str, user: User = Depends(auth.require_role("admin"))):
...
# Restrict to admins or editors
@app.post("/items")
async def create_item(user: User = Depends(auth.require_any_role(["admin", "editor"]))):
...
# Excluded from auth — open to anyone
@app.get("/health")
async def health():
return {"status": "ok"}
Configuration reference
CloudflareAuth(team_domain, role_groups=None)
| Parameter | Type | Required | Description |
|---|---|---|---|
team_domain |
str |
Yes | Your Cloudflare Zero Trust team domain, e.g. your-org.cloudflareaccess.com |
role_groups |
dict[str, str] |
No | Map of Entra ID group Object ID → role name |
User object
| Field | Type | Description |
|---|---|---|
sub |
str |
Cloudflare user identifier (stable across sessions) |
email |
str |
User's email address |
name |
str |
Display name (falls back to email) |
groups |
list[str] |
Entra ID group Object IDs — raw, from JWT |
roles |
list[str] |
Role names mapped from groups via role_groups |
raw |
dict |
Full decoded JWT payload |
Helper methods:
user.has_role("admin")→booluser.has_any_role(["admin", "editor"])→booluser.has_all_roles(["admin", "editor"])→bool
Minimal setup (no RBAC)
If your app only needs SSO (any authenticated user), no environment variables are needed:
auth = CloudflareAuth.from_env()
app.add_middleware(auth.middleware()) # protects every route
@app.get("/data")
async def data():
return [...]
@app.get("/me")
async def me(user: User = Depends(auth.current_user)):
return {"email": user.email}
Health checks and excluded paths
Skip auth for paths that must be publicly reachable:
app.add_middleware(auth.middleware(exclude_paths=["/health", "/metrics"]))
Path matching is prefix-based: /health excludes /health, /health/live, /healthz, etc.
Environment variables
CloudflareAuth.from_env() reads the following environment variables:
| Variable | Description |
|---|---|
CF_ACCESS_GROUP_MAPPING |
JSON {"role_name": "group-object-id", ...}. Role name is the key; group Object ID is the value. The library inverts this internally. |
CF_ACCESS_REQUIRED_GROUPS |
Comma-separated group Object IDs. Each maps to the built-in role "member". Use for simple allow/deny without named roles. |
CF_TEAM_DOMAIN |
Required for from_env(). Your Cloudflare Zero Trust team domain, e.g. your-org.cloudflareaccess.com. |
Both CF_ACCESS_GROUP_MAPPING and CF_ACCESS_REQUIRED_GROUPS may be set at the same
time. If the same group Object ID appears in both, CF_ACCESS_GROUP_MAPPING wins.
Simple allow/deny (no named roles)
CF_ACCESS_REQUIRED_GROUPS=4fb32c31-b135-43d5-a00e-9e566e6aceff,6543851f-fd97-40e8-b097-ab5a71e44ef2
auth = CloudflareAuth.from_env()
@app.get("/data")
async def data(user: User = Depends(auth.require_role("member"))):
...
Named roles
CF_ACCESS_GROUP_MAPPING={"admins":"4fb32c31-...","developers":"6543851f-..."}
auth = CloudflareAuth.from_env()
@app.delete("/items/{id}")
async def delete(id: str, user: User = Depends(auth.require_role("admins"))):
...
How it works under the hood
- On the first request,
PyJWKClientfetcheshttps://<team-domain>/cdn-cgi/access/certsand caches the RSA public keys in memory. - For each request: the token's
kidheader selects the right public key from the cache;PyJWTverifies the RS256 signature,iss, andexp. - Groups are read from the
groupsclaim — an array of Entra ID group Object IDs added by Cloudflare when "Support groups" is enabled on the Azure AD identity provider. - Role mapping is a simple dict lookup: O(1) per group.
Testing
pip install -e ".[dev]"
pytest
Adding to requirements.txt
cc-auth[fastapi]==0.1.0
Pin to a specific version for reproducible production builds. Check PyPI for the latest version.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cc_auth-0.1.0.tar.gz.
File metadata
- Download URL: cc_auth-0.1.0.tar.gz
- Upload date:
- Size: 18.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e73bdba4114db4bf14dba945eb03b145c5282d2bd7aaf26c924934e165fb7804
|
|
| MD5 |
04e0f169220dad75c73e7c5a8606f424
|
|
| BLAKE2b-256 |
9691478ff5c196628a670e7fc0bea14d2f4cccd9ca7d9ef5a50dd72524e77a5f
|
Provenance
The following attestation bundles were made for cc_auth-0.1.0.tar.gz:
Publisher:
publish.yml on computacenter-ro/cc-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cc_auth-0.1.0.tar.gz -
Subject digest:
e73bdba4114db4bf14dba945eb03b145c5282d2bd7aaf26c924934e165fb7804 - Sigstore transparency entry: 2172630914
- Sigstore integration time:
-
Permalink:
computacenter-ro/cc-auth@33f0a65d00b3761ac796bb6835718d379ebe6a0c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/computacenter-ro
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@33f0a65d00b3761ac796bb6835718d379ebe6a0c -
Trigger Event:
push
-
Statement type:
File details
Details for the file cc_auth-0.1.0-py3-none-any.whl.
File metadata
- Download URL: cc_auth-0.1.0-py3-none-any.whl
- Upload date:
- Size: 13.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
952cea4cf9d72bb925645492580ece75d1c4e586611937a2b321cd650c6ce0fb
|
|
| MD5 |
a3990e3ea54545f30870820028cb6725
|
|
| BLAKE2b-256 |
1e31ed58bf1f8b8307309acee23a684634b105cac1d13d2cc0f8bbce291ee989
|
Provenance
The following attestation bundles were made for cc_auth-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on computacenter-ro/cc-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cc_auth-0.1.0-py3-none-any.whl -
Subject digest:
952cea4cf9d72bb925645492580ece75d1c4e586611937a2b321cd650c6ce0fb - Sigstore transparency entry: 2172630953
- Sigstore integration time:
-
Permalink:
computacenter-ro/cc-auth@33f0a65d00b3761ac796bb6835718d379ebe6a0c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/computacenter-ro
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@33f0a65d00b3761ac796bb6835718d379ebe6a0c -
Trigger Event:
push
-
Statement type: