graphene-ict/laravel-cognito-guard

Lean Laravel auth guard that validates AWS Cognito User Pool JWTs. Supports DB-backed users, DB-less mode, cognito:groups → Gates bridge, multi-pool, and Octane.

Maintainers

Package info

github.com/GrapheneICT/laravel-cognito-guard

pkg:composer/graphene-ict/laravel-cognito-guard

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v2.2.0 2026-05-19 11:25 UTC

README

CI codecov Packagist Version Packagist Downloads PHP Version Laravel Version License

A lean Laravel auth guard that validates JSON Web Tokens issued by an AWS Cognito User Pool. Verifies the JWT signature against Cognito's JWKS, enforces standard Cognito claims, and resolves the authenticated user via a UserProvider — or returns a value object in DB-less mode.

Requirements

  • PHP 8.3+
  • Laravel 11 / 12
  • A configured AWS Cognito User Pool

Installation

You can install the package via Composer:

composer require graphene-ict/laravel-cognito-guard

Publish the config file:

php artisan vendor:publish --tag=cognito-guard-config

Set the env vars:

COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX
AWS_REGION=us-east-1
# Optional comma-separated allow-list:
COGNITO_CLIENT_IDS=app-client-1,app-client-2

Usage

DB-backed users (default)

  1. Add provider_id to your users table:

    $table->string('provider_id')->unique()->nullable();
  2. Add provider_id to the model's $fillable.

  3. Register the guard in config/auth.php:

    'guards' => [
        'cognito' => [
            'driver' => 'cognito',
            'provider' => 'cognito',
            'pool' => 'default',
        ],
    ],
    
    'providers' => [
        'cognito' => ['driver' => 'cognito'],
    ],
  4. Protect routes:

    Route::middleware('auth:cognito')->get('/me', fn () => auth()->user());

A new User record is auto-provisioned on the first authenticated request whose sub is not yet known. Disable by setting cognito-guard.user_provider.auto_provision to false.

DB-less mode

For SPA / service-to-service callers that don't need a local users table:

'guards' => [
    'cognito' => [
        'driver' => 'cognito',
        'provider' => 'cognito',
        'pool' => 'default',
        'db_less' => true,
    ],
],

auth()->user() returns a GrapheneICT\CognitoGuard\CognitoUser value object:

$user = auth()->user();
$user->username();        // string|null
$user->email();           // string|null
$user->groups();          // string[]
$user->scopes();          // string[]
$user->claim('sub');      // any single claim
$user->claims();          // raw payload (stdClass)

Per-route scope enforcement

Pool-wide required_scopes is a blunt instrument — every route on the guard demands the same scopes. For finer control, use the cognito.scope middleware:

Route::middleware(['auth:cognito', 'cognito.scope:read:reports'])->get('/reports', ...);
Route::middleware(['auth:cognito', 'cognito.scope:read:reports,write:reports'])->post('/reports', ...);

401 if unauthenticated, 403 if any required scope is missing from the token's scope claim.

Groups → Gates bridge

With cognito-guard.bridge_groups_to_gates enabled (default), entries in the cognito:groups claim become Gate abilities for free:

Gate::allows('admins');                       // true if 'admins' is in cognito:groups
Route::middleware('can:moderators')->...;     // works the same

Multi-pool

// config/cognito-guard.php
'pools' => [
    'default'  => ['user_pool_id' => env('COGNITO_USER_POOL_ID'),  'region' => 'us-east-1'],
    'partners' => ['user_pool_id' => env('PARTNER_POOL_ID'),       'region' => 'us-east-1'],
],

// config/auth.php
'guards' => [
    'cognito'  => ['driver' => 'cognito', 'provider' => 'cognito', 'pool' => 'default'],
    'partners' => ['driver' => 'cognito', 'provider' => 'cognito', 'pool' => 'partners'],
],

Recipes

Configuration reference

See config/cognito-guard.php. Key knobs:

  • pools.<name>.allowed_token_use['access'], ['id'], or both.
  • pools.<name>.allowed_client_ids — empty = accept any; populated = strict allow-list against client_id (access) / aud (id).
  • pools.<name>.required_scopes — every scope must be present in the token's scope claim.
  • pools.<name>.leeway — clock-skew tolerance for exp/nbf/iat, in seconds.
  • jwks.cache_ttl — JWKS cache TTL (default 6h). Stale entries kept 30d and used on Cognito outages.
  • bridge_groups_to_gates — toggle the cognito:groups → Gate bridge.
  • user_provider.sub_claim — which JWT claim supplies the stable identifier. Default sub. Set to cognito:username or a custom attribute when a legacy users table is keyed by something other than the Cognito sub.
  • user_provider.sub_column — the column on the user model that stores the value above (default provider_id).

Events

The guard dispatches two events for observability — listen for them however you wire listeners normally (e.g. in AppServiceProvider::boot()):

  • GrapheneICT\CognitoGuard\Events\CognitoTokenValidated — fired after a JWT is verified and a user is resolved. Carries $user, $claims (stdClass), and $pool.
  • GrapheneICT\CognitoGuard\Events\CognitoTokenRejected — fired when a token fails verification, just before the InvalidTokenException propagates. Carries $exception and $pool.

Raw tokens are intentionally excluded from event payloads — log claims, not credentials.

Diagnostics

php artisan about                          # shows the Cognito Guard section
php artisan cognito:test-token <jwt>       # validates a token + prints a step-by-step diagnosis
php artisan cognito:warm-jwks              # pre-fetch JWKS at deploy time

The cognito:test-token command accepts the raw JWT or a Bearer <jwt> string and prints which validation step passed or failed (signature, issuer, token_use, client_id/aud, scopes, expiry). Add --pool=<name> to test against a non-default pool, or --verbose-claims to dump the full payload.

cognito:warm-jwks pre-fetches and caches the JWKS for every configured pool so the first authenticated request after a cold cache doesn't pay the round-trip, and so reachability to cognito-idp.<region>.amazonaws.com is verified at deploy time. Use --pool=<name> to warm a single pool.

FAQ

InvalidTokenException: Invalid token_use "id". Allowed: access

Your guard is configured to accept access tokens only, but the client is sending an id token. Either send an access token, or widen cognito-guard.pools.<name>.allowed_token_use to ['access', 'id'] (the default).

InvalidTokenException: Token client_id/aud is not in the allow-list

The token's client_id (access tokens) or aud (id tokens) doesn't match COGNITO_CLIENT_IDS. Either add the App Client ID to that env var (comma-separated), or leave allowed_client_ids empty to accept any client.

InvalidTokenException: Invalid issuer

The token was issued by a different User Pool. Check COGNITO_USER_POOL_ID and AWS_REGION.

InvalidTokenException: Token has expired

Either the token genuinely expired, or your server clock has drifted. Bump cognito-guard.pools.<name>.leeway (seconds) to tolerate minor skew — but fix the underlying NTP issue, don't paper over it long-term.

JwksFetchException: Failed to fetch JWKS from https://cognito-idp.<region>.amazonaws.com/...

Your app can't reach Cognito's JWKS endpoint. Check egress to cognito-idp.<region>.amazonaws.com. The guard will serve from stale cache for up to 30 days if the JWKS was ever fetched successfully (toggle via cognito-guard.jwks.stale_on_error).

The user returned from auth()->user() isn't my Eloquent model

If your guard config has 'db_less' => true, the package returns a CognitoUser value object built from JWT claims instead of looking up a database row. Set db_less => false (the default) and configure cognito-guard.user_provider.model to point at your Eloquent user model.

Auth provider "..." is not configured

You set 'provider' => 'cognito' on the guard but didn't add a cognito entry under auth.providers. Add:

'providers' => ['cognito' => ['driver' => 'cognito']],
Multiple Cognito pools — how do I authenticate against the second one?

Register a second guard with a different pool key and pick which one to apply per route:

'guards' => [
    'cognito'  => ['driver' => 'cognito', 'provider' => 'cognito', 'pool' => 'default'],
    'partners' => ['driver' => 'cognito', 'provider' => 'cognito', 'pool' => 'partners'],
],

Then Route::middleware('auth:partners')->....

Testing your app

The guard exposes an actingAs() helper so your test suite doesn't need to forge JWTs:

use Illuminate\Support\Facades\Auth;

// DB-less: pass JWT claims, get a CognitoUser back.
Auth::guard('cognito')->actingAs([
    'sub' => 'user-uuid',
    'email' => 'alice@example.com',
    'cognito:groups' => ['admins'],
]);

// DB-backed: pass your Eloquent user + the claims you want attached
// (so the groups → Gates bridge sees them).
Auth::guard('cognito')->actingAs($user, ['cognito:groups' => ['editors']]);

After the call, auth()->user(), auth()->id(), Gate::allows('admins'), and routes behind auth:cognito all behave as if a real token had been verified.

Testing the package

composer install
composer test
composer analyse

Upgrading from v1

Breaking changes — see UPGRADING.md.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.