NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Jwt::fake() swaps the manager for a recording fake over an in-memory RSA key pair — no key files needed, and an empty jwt.issuer, jwt.audience or service secret is filled with test values. It moves the package’s denylist onto a private in-memory cache store, so no Redis (or whatever jwt.denylist.store names) is needed; a Denylist binding of your own is left alone. Tokens are still really signed and verified, and every mint, service-token issue and deny is recorded — through the facade, an injected JwtManager, guard(), services() or denylist():

use App\Models\User;
use RoundlyConsulting\Jwt\Facades\Jwt;
use RoundlyConsulting\Jwt\Jose\Claims;
use RoundlyConsulting\Jwt\UserTokens\AccessTokenRequest;

it('mints, calls billing and logs out', function (): void {
    $fake = Jwt::fake();   // in-memory RSA keys — no key files

    $issued = Jwt::mintAccessToken(AccessTokenRequest::for('42'));
    Jwt::services()->issue('billing', ['job' => 'sync']);
    Jwt::logout($issued);

    $fake->assertMinted();                                                  // any user token
    $fake->assertMinted(fn (Claims $claims): bool => $claims->string('sub') === '42');
    $fake->assertServiceTokenIssued('billing');
    $fake->assertServiceTokenIssued('billing', fn (Claims $claims): bool => $claims->get('job') === 'sync');
    $fake->assertDenied($issued->jti);
    $fake->assertDenied();                                                  // any jti

    expect($fake->minted())->toHaveCount(1);   // list<RecordedToken> — ->token, ->claims
});

it('serves the profile to an authenticated user', function (): void {
    $fake = Jwt::fake();
    $user = User::factory()->create();

    // Authenticate a jwt guard for the rest of the test — no hand-built token:
    $fake->actingAs(['sub' => (string) $user->id, 'permissions' => ['posts.edit']], 'api');

    $this->getJson('/me')->assertOk();

    $fake->assertNothingMinted();            // actingAs() is not recorded as a mint
    $fake->assertNothingIssuedToServices();
    $fake->assertNothingDenied();
});

Assertions

AssertNegativePasses when
assertMinted(?Closure $where = null)assertNothingMinted()A user token was minted — matching the Claims callback when given — through the facade, an injected JwtManager or guard().
assertServiceTokenIssued(?string $audience = null, ?Closure $where = null)assertNothingIssuedToServices()A service token was issued — for that audience and matching the callback, when given — including request() and authenticate().
assertDenied(?string $jti = null)assertNothingDenied()A jti (or that jti) was denylisted — denylist()->deny() / denyToken(), logout() or denyClaims().

$fake->minted(), serviceTokens() and denied() return what was recorded — RecordedToken objects with token and claims, and the denied jtis.

Authenticating a guard

actingAs($claims, $guard) mints a real token for the guard’s audience and scope — not recorded as a mint — and sends it as the bearer of every later request that has no Authorization header of its own. It returns the IssuedToken, e.g. for $this->withToken(). With an Eloquent provider, sub must be a real user’s key; a guard with token_version needs a matching tv.

Laravel’s actingAs()

Laravel’s own actingAs() works on both guards too, with no token at all — the guard keeps the user you set until Auth::guard('api')->forgetUser():

$this->actingAs($user, 'api')->getJson('/me')->assertOk();          // Eloquent or TokenUser
$this->actingAs($serviceIdentity, 'service')->postJson('/internal/sync')->assertOk();

Jwt::guard('api')->claims() returns a set TokenUser’s claims (and Jwt::services()->claims() a set ServiceIdentity’s); any other user set this way has none. Call Jwt::fake() before Laravel’s actingAs() — the fake rebuilds the guards, dropping a user set earlier — and a later $fake->actingAs() replaces it.

Testing against a real keypair

To exercise your own key configuration, test with real tokens instead. Generate a throwaway keypair for the suite once — relative paths resolve from the application root:

JWT_PRIVATE_KEY_PATH=tests/keys/jwt-private.pem \
JWT_PUBLIC_KEY_PATH=tests/keys/jwt-public.pem \
php artisan jwt:generate-keys

User tokens and the jwt guard

Configure the pins and keys per test, keep the denylist in memory with the array cache store, and assert through the HTTP layer:

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use RoundlyConsulting\Jwt\Events\UserTokenIssued;
use RoundlyConsulting\Jwt\Facades\Jwt;
use RoundlyConsulting\Jwt\Jose\Exceptions\TokenExpired;
use RoundlyConsulting\Jwt\UserTokens\AccessTokenRequest;

beforeEach(function (): void {
    config([
        'jwt.issuer' => 'jwt-issuer',
        'jwt.audience' => 'web',
        'jwt.private_key_path' => base_path('tests/keys/jwt-private.pem'),
        'jwt.public_key_path' => base_path('tests/keys/jwt-public.pem'),
        'jwt.denylist.store' => 'array',
        'auth.guards.api' => ['driver' => 'jwt'],
    ]);

    Route::middleware('auth:api')->get('/me', fn () => ['id' => auth()->id()]);
});

afterEach(function (): void {
    CarbonImmutable::setTestNow();
});

it('authenticates a bearer token and rejects it after logout', function (): void {
    $issued = Jwt::mintAccessToken(AccessTokenRequest::for('user-1'));

    $this->withToken($issued->token)->getJson('/me')
        ->assertOk()
        ->assertJson(['id' => 'user-1']);

    Jwt::logout($issued);

    $this->withToken($issued->token)->getJson('/me')->assertUnauthorized();
});

it('rejects an expired token', function (): void {
    $issued = Jwt::mintAccessToken(AccessTokenRequest::for('user-1')->ttl(60));

    CarbonImmutable::setTestNow(CarbonImmutable::now()->addMinutes(5));

    expect(fn () => Jwt::verify($issued->token))->toThrow(TokenExpired::class);
});

it('dispatches UserTokenIssued', function (): void {
    Event::fake();

    $issued = Jwt::mintAccessToken(AccessTokenRequest::for('user-1'));

    Event::assertDispatched(UserTokenIssued::class, fn (UserTokenIssued $e): bool => $e->jti === $issued->jti);
});

Time checks read CarbonImmutable::now(), so CarbonImmutable::setTestNow() moves a token past its expiry — reset it after each test.

Service tokens

Set a 32-byte-plus secret, this app’s service name and the service-jwt guard, then assert inbound calls and outbound headers with Http::fake():

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use RoundlyConsulting\Jwt\Facades\Jwt;

beforeEach(function (): void {
    config([
        'jwt.service.name' => 'notifications',
        'jwt.service.secret' => 'a-very-secret-service-key-0123456789',
        'jwt.service.issuer' => 'billing',
        'auth.guards.service' => ['driver' => 'service-jwt'],
    ]);
});

it('accepts a signed internal call', function (): void {
    Route::middleware('auth:service')->get('/internal', fn () => ['iss' => auth()->id()]);

    $token = Jwt::services()->issue('notifications')->token;

    $this->withToken($token)->getJson('/internal')
        ->assertOk()
        ->assertJson(['iss' => 'billing']);
});

it('signs outbound internal calls', function (): void {
    Http::fake();

    Jwt::services()->request('notifications')->post('https://notifications.internal/send', ['id' => 1]);

    Http::assertSent(fn ($request) => $request->hasHeader('Authorization'));
});

The package’s own suite runs with composer test:

composer test

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.