returnearly / laravel-cloudflare-zero-trust
Security-first Cloudflare Access (Zero Trust) authentication for Laravel
Package info
github.com/returnearly/laravel-cloudflare-zero-trust
pkg:composer/returnearly/laravel-cloudflare-zero-trust
Requires
- php: ^8.3
- firebase/php-jwt: ^6.11|^7.0
- illuminate/auth: ^12.0|^13.0
- illuminate/cache: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- returnearly/actions-pattern: ^2.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
This package is auto-updated.
Last update: 2026-08-06 16:46:37 UTC
README
Security-first Cloudflare Access (Zero Trust) authentication for Laravel.
When you put an application behind Cloudflare Access, Cloudflare authenticates every request at the edge — via your identity provider for humans, or via service tokens for machines — and forwards a signed JWT to your origin in the Cf-Access-Jwt-Assertion header. This package verifies that JWT cryptographically and turns it into a first-class Laravel authentication primitive:
- Route middleware that fails closed: signature, issuer, audience, expiry, and claim-shape checks before anything is trusted
- Typed principals — SSO users and service tokens are distinct, verified identities, not loosely-inspected claims
- Multiple accounts and applications — protect your app, your API, and Horizon with different Access policies from one config file
- A Laravel auth guard so
auth()->user()works, optionally resolving Access users onto your ownUsermodel - On-demand identity enrichment — groups, IdP attributes, and device posture fetched lazily and cached
- Cached, rate-limited JWKS handling with automatic key-rotation recovery
- Events for observability on every authentication success and failure
- Request-scoped state — safe under Laravel Octane and other long-lived workers
How it works
Browser / API client
│
▼
Cloudflare Access (edge) ── authenticates via IdP SSO or service token
│
▼ adds Cf-Access-Jwt-Assertion: <signed JWT>
Your Laravel origin
│
▼
cloudflare-access middleware
1. Reads the JWT from the assertion header
2. Verifies RS256 signature against your team's JWKS (cached)
3. Validates issuer, audience, expiry, and token type
4. Classifies the request as a user or service principal
5. Rejects with 401/403 — or lets the request through
The package never trusts unverified input: principal classification happens only after cryptographic verification, and CF-Access-Client-Id / CF-Access-Client-Secret headers are never treated as origin credentials (Cloudflare validates those at the edge and mints a signed JWT for your origin).
Requirements
- PHP 8.3, 8.4, or 8.5
- Laravel 12 or 13
- An application protected by Cloudflare Access (typically via Cloudflare Tunnel)
Installation
composer require returnearly/laravel-cloudflare-zero-trust
Publish the config file:
php artisan vendor:publish --tag="cloudflare-zero-trust-config"
Quick start
1. Find your team domain (Cloudflare Zero Trust dashboard → Settings → Custom Pages) and the Application Audience (AUD) tag for each Access application (Access → Applications → your app → Overview).
2. Define your accounts and applications in config/cloudflare-zero-trust.php:
use ReturnEarly\CloudflareZeroTrust\Enums\PrincipalKind; 'accounts' => [ 'acme' => [ 'team_domain' => 'https://acme.cloudflareaccess.com', 'applications' => [ 'internal-api' => [ 'audience' => [env('CLOUDFLARE_INTERNAL_API_AUD')], 'principals' => [PrincipalKind::User, PrincipalKind::Service], ], ], ], ],
3. Protect your routes:
Route::middleware('cloudflare-access:internal-api')->group(function () { Route::get('/reports', ReportController::class); });
4. Use the verified identity:
use ReturnEarly\CloudflareZeroTrust\Support\Access; Access::user()?->email(); // "jane@example.com" for SSO users Access::service()?->commonName(); // "my-worker.access" for service tokens
That's it. Invalid, expired, or missing tokens are rejected before your route runs.
Configuration
Accounts and applications
Each account corresponds to one Cloudflare Zero Trust team (one team domain, one JWKS endpoint). Each application under it corresponds to a Cloudflare Access application with one or more AUD tags. Application names must be unique across all accounts, because middleware and guards refer to applications by name alone.
use ReturnEarly\CloudflareZeroTrust\Enums\PrincipalKind; 'accounts' => [ 'acme' => [ // Your team domain. Also accepted under the key 'issuer'. 'team_domain' => 'https://acme.cloudflareaccess.com', // Optional. Defaults to {team_domain}/cdn-cgi/access/certs // 'jwks_url' => 'https://acme.cloudflareaccess.com/cdn-cgi/access/certs', 'applications' => [ 'internal-api' => [ // One or more AUD tags. Also accepted under the key 'audiences'. 'audience' => [env('CLOUDFLARE_INTERNAL_API_AUD')], // Which principal kinds may authenticate. // Defaults to [PrincipalKind::User] — service tokens are opt-in. 'principals' => [PrincipalKind::User, PrincipalKind::Service], // Optional. Set false to forbid identity enrichment for this app. 'identity_enabled' => true, ], 'horizon' => [ 'audience' => [env('CLOUDFLARE_HORIZON_AUD')], 'principals' => [PrincipalKind::User], // humans only ], ], ], ],
Configuration is validated eagerly — a missing audience, an unknown principal kind, or a non-HTTPS team domain throws InvalidConfiguration rather than silently misbehaving.
Package options
All top-level options with their environment variables and defaults:
| Config key | Env variable | Default | Purpose |
|---|---|---|---|
enabled |
CLOUDFLARE_ZERO_TRUST_ENABLED |
true |
Master switch. When false, middleware passes requests through unvalidated and the guard returns no user. |
header |
— | Cf-Access-Jwt-Assertion |
Header containing the Access JWT. The CF_Authorization cookie is intentionally not read. |
guard |
CLOUDFLARE_ZERO_TRUST_GUARD |
cloudflare |
Default guard name, for your own reference in config/auth.php. |
clock_leeway |
CLOUDFLARE_ZERO_TRUST_CLOCK_LEEWAY |
30 |
Seconds of clock skew tolerated when checking exp/nbf/iat. |
jwks.cache_store |
CLOUDFLARE_ZERO_TRUST_CACHE_STORE |
default store | Cache store for JWKS keys and identity payloads. Must support atomic locks. |
jwks.cache_ttl |
CLOUDFLARE_ZERO_TRUST_JWKS_CACHE_TTL |
3600 |
Seconds to cache the JWKS key set. |
jwks.connect_timeout |
CLOUDFLARE_ZERO_TRUST_JWKS_CONNECT_TIMEOUT |
2 |
JWKS HTTP connect timeout (seconds). |
jwks.timeout |
CLOUDFLARE_ZERO_TRUST_JWKS_TIMEOUT |
5 |
JWKS HTTP request timeout (seconds). |
jwks.refresh_rate_limit |
CLOUDFLARE_ZERO_TRUST_JWKS_REFRESH_RATE_LIMIT |
10 |
Max unknown-kid triggered refreshes per window. |
jwks.refresh_rate_window |
CLOUDFLARE_ZERO_TRUST_JWKS_REFRESH_RATE_WINDOW |
60 |
Rate-limit window (seconds). |
identity.enabled |
CLOUDFLARE_ZERO_TRUST_IDENTITY_ENABLED |
true |
Global switch for identity enrichment. |
identity.connect_timeout |
CLOUDFLARE_ZERO_TRUST_IDENTITY_CONNECT_TIMEOUT |
2 |
Identity HTTP connect timeout (seconds). |
identity.timeout |
CLOUDFLARE_ZERO_TRUST_IDENTITY_TIMEOUT |
5 |
Identity HTTP request timeout (seconds). |
Protecting routes with middleware
The cloudflare-access middleware takes the application name as its parameter:
Route::middleware('cloudflare-access:internal-api')->group(function () { // API endpoints reachable by SSO users and service tokens }); Route::middleware('cloudflare-access:horizon')->group(function () { // Human-only admin surface });
To protect Laravel Horizon, reference the middleware in config/horizon.php:
'middleware' => ['web', 'cloudflare-access:horizon'],
What a rejected request gets
The middleware fails closed and never leaks why a token was rejected to the client (the detail goes into events instead):
| Status | When | Body |
|---|---|---|
401 |
Missing, malformed, expired, or otherwise invalid token | Unauthenticated. |
403 |
Token is valid but the principal kind is not allowed for this application | Forbidden. |
500 |
The middleware references an unknown application name | Cloudflare Access is misconfigured. |
503 |
The Cloudflare JWKS endpoint is unreachable and no cached keys exist | Service Unavailable. |
Requests that expect JSON receive {"message": "..."}; everything else gets a plain-text body.
Working with the verified principal
Once a request has been verified (by the middleware or the guard), the Access support class exposes the verified identity anywhere in your app:
use ReturnEarly\CloudflareZeroTrust\Support\Access; Access::token(); // ?VerifiedAccessToken — raw JWT, header, claims, expiry Access::principal(); // ?Principal — UserPrincipal or ServicePrincipal Access::user(); // ?UserPrincipal — null if this is a service request Access::service(); // ?ServicePrincipal — null if this is a user request Access::isUser(); // bool Access::isService(); // bool Access::commonName(); // ?string — the service token's client ID Access::applicationIs('horizon'); // bool — which named application authenticated this request Access::identity(); // ?AccessIdentity — lazy enrichment; throws on failure Access::tryIdentity(); // ?AccessIdentity — null instead of throwing Access::hasGroup('Finance-Team'); // bool — never throws
All helpers return null/false on requests that were not verified, so they are safe to call unconditionally. State lives on the request instance (not the container), so it cannot bleed between requests under Octane; every helper also accepts an optional Request argument if you need to inspect a specific request.
UserPrincipal (SSO users)
A user token must carry type=app, a non-empty sub, and a valid email — otherwise it is rejected.
$user = Access::user(); $user->subject(); // stable Cloudflare user ID (JWT "sub") $user->email(); $user->country(); // ?string, e.g. "US" $user->accountName(); // config account name, e.g. "acme" $user->applicationName(); $user->claims(); // full validated claim set
ServicePrincipal (service tokens)
A service token must carry type=app, an empty sub, and a non-empty common_name.
$service = Access::service(); $service->commonName(); // the service token client ID, e.g. "my-worker.access" $service->claims();
Both principal types implement Laravel's Authenticatable contract, so they also work with the auth guard below. kind() returns a PrincipalKind enum (PrincipalKind::User or PrincipalKind::Service).
Using the auth guard
Register a guard with the cloudflare driver in config/auth.php:
'guards' => [ 'cloudflare' => [ 'driver' => 'cloudflare', 'application' => 'internal-api', ], ],
Then authenticate the standard Laravel way:
Route::middleware('auth:cloudflare')->get('/me', function () { return auth('cloudflare')->user(); // UserPrincipal or ServicePrincipal }); auth('cloudflare')->check(); auth('cloudflare')->id(); // user "sub" or service "common_name"
The guard and the middleware share one verification per request: whichever runs first stores the verified token on the request, and the other reuses it. The guard works standalone, but the package middleware gives you richer rejection behavior (403 vs 401, failure events), so stacking both is a good default:
Route::middleware(['cloudflare-access:internal-api', 'auth:cloudflare'])->group(/* ... */);
Resolving Access users onto your own user model
By default the guard returns the package's stateless principals. To map SSO users onto your application's User model, implement ApplicationUserResolver:
use Illuminate\Contracts\Auth\Authenticatable; use ReturnEarly\CloudflareZeroTrust\Contracts\ApplicationUserResolver; use ReturnEarly\CloudflareZeroTrust\Principals\UserPrincipal; class CloudflareUserResolver implements ApplicationUserResolver { public function resolve(UserPrincipal $principal): ?Authenticatable { return User::firstWhere('email', $principal->email()); } }
'guards' => [ 'cloudflare' => [ 'driver' => 'cloudflare', 'application' => 'internal-api', 'resolver' => App\Auth\CloudflareUserResolver::class, ], ],
A closure works too, if you configure the guard at runtime. Resolution rules:
- The resolver only runs for user principals; service principals are always returned as-is.
- If the resolver returns
null, the guard falls back to theUserPrincipal(the request stays authenticated). Throw inside the resolver if an unknown SSO user should be treated as an error.
Service tokens
Machine-to-machine clients authenticate to Cloudflare with CF-Access-Client-Id / CF-Access-Client-Secret headers. Cloudflare validates those at the edge and forwards a signed JWT like any other request — this package only ever validates the resulting Cf-Access-Jwt-Assertion and never treats the client ID/secret pair as origin credentials.
Service tokens are opt-in per application. The default principal set is users only, so add PrincipalKind::Service where machines should be allowed:
use ReturnEarly\CloudflareZeroTrust\Enums\PrincipalKind; 'internal-api' => [ 'audience' => [env('CLOUDFLARE_INTERNAL_API_AUD')], 'principals' => [PrincipalKind::User, PrincipalKind::Service], ],
An application without PrincipalKind::Service rejects service-token JWTs with 403 even when they are cryptographically valid. Which service tokens can mint a JWT for an application's audience is governed by your Cloudflare Access policy — manage that allowlist in the Cloudflare dashboard, not at the origin.
Identity enrichment
The Access JWT deliberately carries a minimal claim set. When you need more — IdP groups, device posture, geo — the package fetches the full identity from your team domain's /cdn-cgi/access/get-identity endpoint:
$identity = Access::identity(); // ?AccessIdentity $identity->email; $identity->userUuid; $identity->geo; // ?string $identity->ip; // ?string $identity->groups; // list<string> of IdP group names $identity->idp; // raw IdP payload $identity->devicePosture; // device posture checks $identity->raw; // the complete payload Access::hasGroup('Finance-Team'); // convenience shortcut
Behavior to be aware of:
- Lazy: the endpoint is only called when you ask for the identity. Ordinary authentication never touches it.
- Cached: responses are cached (keyed by the token's identity nonce) for no longer than the JWT's remaining lifetime.
- Users only: identity lookups return
nullfor service principals and on unverified requests. - Failure handling:
Access::identity()throwsIdentityUnavailableif enrichment is disabled or the endpoint fails; useAccess::tryIdentity()orAccess::hasGroup()when you'd rather getnull/false. - Can be disabled globally (
CLOUDFLARE_ZERO_TRUST_IDENTITY_ENABLED=false) or per application ('identity_enabled' => false).
Events
Every authentication outcome dispatches an event you can hook for logging, metrics, or alerting. No event ever contains the raw JWT.
| Event | When | Notable payload |
|---|---|---|
AccessAuthenticated |
Middleware verified a token | account, application, principalKind (enum), key ID |
AccessAuthenticationFailed |
Middleware rejected a request | reasonCode, account, application, safe context |
IdentityEnriched |
Identity fetched from Cloudflare | account, application, subject |
JwksRefreshed |
JWKS key set fetched and cached | account, issuer, keyCount |
JwksRefreshFailed |
JWKS fetch failed | account, issuer, reason |
reasonCode values include missing_token, malformed_token, invalid_algorithm, unknown_kid, invalid_signature_or_claims, invalid_issuer, invalid_audience, invalid_type, missing_sub, invalid_email, disallowed_principal, and jwks_unavailable — enough granularity to alert on tampering separately from misconfiguration.
use ReturnEarly\CloudflareZeroTrust\Events\AccessAuthenticationFailed; Event::listen(function (AccessAuthenticationFailed $event) { Log::warning('Cloudflare Access rejected a request', [ 'reason' => $event->reasonCode, 'application' => $event->application, ]); });
JWKS caching and key rotation
Cloudflare rotates your team's signing keys periodically. The package handles this without manual intervention:
- Keys are fetched from
{team_domain}/cdn-cgi/access/certsand cached (default 1 hour, configurable store and TTL). The configured store must support atomic locks (Redis, Memcached, database, file, etc.). - When a token arrives signed by an unknown
kid, the key set is refreshed immediately — subject to a rate limit (default 10 refreshes per 60 seconds) so attackers can't use boguskids to hammer the JWKS endpoint. - Concurrent refreshes are deduplicated with a cache lock on the same store.
- If a refresh fails but cached keys exist, the cached keys are used; if no keys are available at all, requests fail closed with
503. - Only RSA/RS256 signing keys are accepted;
algconfusion (e.g.noneor HS256 tokens) is rejected before signature verification.
Local development
Validation is on by default everywhere. There is deliberately no environment-name bypass — an app that skips auth because APP_ENV=local is one typo away from skipping it in production. To work without Cloudflare in front of you, disable the package explicitly:
CLOUDFLARE_ZERO_TRUST_ENABLED=false
When disabled, the middleware passes requests through and the guard returns no user.
Alternatively, run cloudflared locally and keep validation on — this exercises the real code path.
Testing your application
For feature tests that don't exercise authentication, disable the package in phpunit.xml:
<env name="CLOUDFLARE_ZERO_TRUST_ENABLED" value="false"/>
To test the authentication path itself, fake the JWKS endpoint and mint your own RS256 tokens with firebase/php-jwt (already installed as a dependency of this package). Generate an RSA key pair in your test, serve its public JWK from the faked certs URL, and sign tokens with the private key:
use Firebase\JWT\JWT; use Illuminate\Support\Facades\Http; Http::fake([ 'https://acme.cloudflareaccess.com/cdn-cgi/access/certs' => Http::response([ 'keys' => [$publicJwk], // ['kty' => 'RSA', 'kid' => 'test-key', 'use' => 'sig', 'alg' => 'RS256', 'n' => ..., 'e' => ...] ]), ]); $jwt = JWT::encode([ 'iss' => 'https://acme.cloudflareaccess.com', 'aud' => ['your-aud-tag'], 'type' => 'app', 'sub' => 'user-uuid', 'email' => 'user@example.com', 'iat' => time(), 'exp' => time() + 3600, ], $privateKeyPem, 'RS256', 'test-key'); $this->withHeader('Cf-Access-Jwt-Assertion', $jwt) ->getJson('/internal-api/reports') ->assertOk();
For a service-token request, replace sub/email with 'sub' => '' and 'common_name' => 'my-client-id.access'. See this package's own test suite for a complete key-pair fixture you can adapt.
Security model
What the package guarantees:
- Claims are trusted only after RS256 signature verification against your team's JWKS.
- Tokens are bound to the configured issuer and to the specific application's AUD tags — a token minted for one application cannot be replayed against another.
- Principal classification (user vs. service) is strict; ambiguous claim shapes are rejected.
- Verified auth state is request-scoped, so it cannot leak between requests under Octane or queue workers.
- Failures are closed: configuration errors, JWKS outages, and unknown applications reject requests rather than letting them through.
- Raw JWTs and secrets are never included in events, logs, or error responses.
What the package cannot do for you:
Important
Your origin must not be directly reachable from the internet. A valid Access JWT is a bearer credential: anyone who obtains one can replay it against an exposed origin until it expires, bypassing Cloudflare's checks entirely. Put your origin behind Cloudflare Tunnel, mTLS, or equivalent network controls so the only path to your app is through Cloudflare.
See SECURITY.md for the full deployment threat model and the vulnerability disclosure policy.
Development
composer test # Pest test suite composer analyse # PHPStan static analysis composer format # Laravel Pint code style
Contributing
See CONTRIBUTING.md.
Credits
License
The MIT License (MIT). See LICENSE.md for details.
