Looking to hire Laravel developers? Try LaraJobs

laravel-cloudflare-zero-trust maintained by returnearly

Description
Security-first Cloudflare Access (Zero Trust) authentication for Laravel
Last update
2026/08/06 18:46 (dev-feature/per-application-enabled)
Links
Downloads
0

Comments
comments powered by Disqus

Cloudflare Zero Trust for Laravel

Latest Version on Packagist GitHub Tests Action Status PHPStan Total Downloads

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 own User model
  • 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 the UserPrincipal (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 null for service principals and on unverified requests.
  • Failure handling: Access::identity() throws IdentityUnavailable if enrichment is disabled or the endpoint fails; use Access::tryIdentity() or Access::hasGroup() when you'd rather get null/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/certs and 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 bogus kids 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; alg confusion (e.g. none or 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.