---
title: "Testing — JWT for Laravel | Roundly"
description: "Jwt::fake() records every mint, service token and deny over in-memory keys, with actingAs() and actingAsService() — or use a real keypair."
url: https://roundly-consulting.com/open-source/docs/jwt-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[JWT for Laravel](https://roundly-consulting.com/open-source/docs/jwt-for-laravel.md)

# Testing

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. That store reads jwt.denylist.prefix and jwt.leeway exactly like the real binding (blank means jwt:denylist: and 10 s), so a denial lasts until exp + leeway, as in production. 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():

```php
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

| Assert | Negative | Passes 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(). The token lives jwt.ttl seconds, read exactly like the real issuer reads it (an env string such as '60' counts, blank means 900, an invalid value throws JwtMisconfigured), so travelling past it makes the guard reject the token, as in production. With an Eloquent provider, sub must be a real user’s key; a guard with token\_version needs a matching tv.

## Acting as another service

actingAsService($issuer, $claims = \[\], ?$audience = null) does the same for the service-jwt guards, as a call from another service: a real HS256 service token with iss = $issuer, aud = this service’s name (jwt.service.name, else app.service, else a slug of app.name — what the service guard pins; $audience overrides it, e.g. for a negative test), scope = service and jwt.service.ttl. It is signed with $issuer’s own secret when the jwt.service.secrets map names it, else with the shared jwt.service.secret; an issuer the map leaves out gets a throwaway secret, so it fails like a real stranger. The guard still decides: an issuer off jwt.service.issuers, an unmapped issuer or a foreign audience is a 401.

The token goes out through the same bearer slot as actingAs() (the later call wins), and every service-jwt guard forgets a caller set earlier with Laravel’s actingAs($identity, 'service'). Extra $claims with a registered name throw ServiceAuthMisconfigured, like services()->issue(). A one-off issuer mints it, so the app’s own outbound tokens still claim this service, and nothing is recorded in serviceTokens() or announced with ServiceTokenIssued.

forgetActing() undoes both: later requests go out without the fake’s bearer — Laravel’s withoutToken() cannot do that, since the fake attaches its bearer to every request without an Authorization header — the current request loses it, and each guard the fake authenticated forgets its user. A bearer the test set itself is left alone:

```php
$fake = Jwt::fake();

$fake->actingAsService('billing', ['run' => 'r-1']);
$this->postJson('/internal/sync')->assertOk();    // auth:service sees iss = billing

$fake->forgetActing();
$this->postJson('/internal/sync')->assertUnauthorized();
```

## 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():

```php
$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() or actingAsService() replaces it. To send a real signed token from another service instead, use $fake->actingAsService($issuer).

## 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:

```bash
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:

```php
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():

```php
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:

```bash
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/jwt-for-laravel.md)
