Skip to main content
Beyond JWT signing and device tracking, the package exposes three additional configuration areas: an optional bearer identity cache that reduces database pressure on high-traffic bearer paths, a timebox that prevents timing side-channels on HTTP Basic credential checks, and per-guard layering for identifier fields and principal resolvers. All three are off or at safe defaults out of the box.

Resolution cache

Cross-request resolution caching is disabled by default. When enabled, it applies exclusively to JWT bearer identity rehydration through model providers — the lookup that turns the sub claim in an access token back into a live identity model. Everything else stays live on every request. What is cached: the identity model resolved from the bearer token’s sub claim. What stays live on every request regardless of cache state:
  • HTTP Basic credential lookups
  • Bearer-path device resolution (did claim)
  • Principal resolution (pid claim)
  • The entire refresh flow — revocation and replay detection remain device-backed and immediate

Config block

config/authentication.php
'resolution_cache' => [
    'store' => env('AUTHENTICATION_RESOLUTION_CACHE_STORE'),
    'jwt'   => [
        'identity_ttl_seconds'  => (int) env('AUTHENTICATION_RESOLUTION_CACHE_JWT_IDENTITY_TTL_SECONDS', 0),
        'principal_ttl_seconds' => (int) env('AUTHENTICATION_RESOLUTION_CACHE_JWT_PRINCIPAL_TTL_SECONDS', 0),
    ],
],

Fields

resolution_cache.store
string
The Laravel cache store to use for bearer identity caching. Accepts any store name configured in config/cache.php (for example, redis, memcached, dynamodb). When left empty or unset, the cache is disabled regardless of TTL settings. You must set both this and identity_ttl_seconds to a non-zero value to enable caching.
.env
AUTHENTICATION_RESOLUTION_CACHE_STORE=redis
resolution_cache.jwt.identity_ttl_seconds
number
default:"0"
How long a cached bearer identity entry lives, in seconds. 0 disables the cache entirely. When non-zero, a cache hit on the bearer path skips the provider lookup for the identity model for subsequent requests within this window. Active-state checks, pid matching, and did device validation still run live on every request.
.env
AUTHENTICATION_RESOLUTION_CACHE_JWT_IDENTITY_TTL_SECONDS=300
resolution_cache.jwt.principal_ttl_seconds
number
default:"0"
Reserved for future use. Keep this at 0. Principal resolution runs live on every request and is not currently cached.
.env
AUTHENTICATION_RESOLUTION_CACHE_JWT_PRINCIPAL_TTL_SECONDS=0

Enabling the cache

1

Configure a cache store

Set AUTHENTICATION_RESOLUTION_CACHE_STORE to a named store in your config/cache.php. A persistent store such as Redis is required — the in-memory array driver does not share state across requests.
.env
AUTHENTICATION_RESOLUTION_CACHE_STORE=redis
AUTHENTICATION_RESOLUTION_CACHE_JWT_IDENTITY_TTL_SECONDS=300
2

Wire explicit invalidation

Add a model observer (or equivalent write-path hook) to your identity model that calls ResolutionCacheInvalidator::forgetIdentity() whenever the identity is saved or deleted. This step is mandatory. Skipping it means cached entries survive identity updates, bans, or deletions until the TTL expires.
app/Observers/UserObserver.php
use App\Models\User;
use SineMacula\Laravel\Authentication\Cache\ResolutionCacheInvalidator;

final class UserObserver
{
    public function saved(User $user): void
    {
        app(ResolutionCacheInvalidator::class)->forgetIdentity($user);
    }

    public function deleted(User $user): void
    {
        app(ResolutionCacheInvalidator::class)->forgetIdentity($user);
    }
}
Register the observer in a service provider:
app/Providers/AppServiceProvider.php
use App\Models\User;
use App\Observers\UserObserver;

public function boot(): void
{
    User::observe(UserObserver::class);
}
3

Handle identifier changes

If your identity model’s auth identifier (typically email or id) can change, you must also invalidate the previous identifier before the save is committed — otherwise the old cache entry remains unaddressed:
app/Observers/UserObserver.php
public function saving(User $user): void
{
    if ($user->isDirty($user->getAuthIdentifierName())) {
        $previousIdentifier = $user->getOriginal($user->getAuthIdentifierName());

        app(ResolutionCacheInvalidator::class)->forgetIdentity($user, $previousIdentifier);
    }
}

public function saved(User $user): void
{
    app(ResolutionCacheInvalidator::class)->forgetIdentity($user);
}
Do not enable the resolution cache without the invalidation wiring in place. A cached entry that survives a suspension, ban, or deletion allows the affected identity to continue authenticating on the bearer path until the TTL expires, even when CanBeActive::isActive() returns false. The active-state check runs live — but only after the identity model is resolved. A stale cache entry short-circuits the provider lookup and delivers a cached model whose in-memory isActive() reflects the state at cache-fill time.

Credential timebox

The basic guard wraps every credential check in Illuminate\Support\Timebox to prevent timing side-channels. Without a timebox, an attacker can distinguish “username not found” (fast) from “wrong password” (slow, bcrypt) by measuring response latency, leaking the existence of accounts.

Config block

config/authentication.php
'timebox' => [
    'credentials_microseconds' => (int) env('AUTHENTICATION_TIMEBOX_CREDENTIALS_US', 400000),
],

Field

timebox.credentials_microseconds
number
default:"400000"
The minimum number of microseconds the credential-validation path must take. The default of 400000 (400 ms) is intentionally above the worst-case cost of bcrypt at cost factor 12, which typically runs between 150 ms and 250 ms. If you use a higher bcrypt cost or a different hasher, increase this value to exceed your worst-case hash time.
.env
AUTHENTICATION_TIMEBOX_CREDENTIALS_US=400000
Setting this too low defeats the timing protection. Setting it higher than necessary adds latency to every Basic auth request. Choose a value that exceeds your hasher’s measured worst-case cost with a comfortable margin.
The timebox only applies to the credential-validation path of the basic guard. JWT bearer authentication does not involve password hashing and is not subject to this timing constraint.

Per-guard identifier field and principal resolvers

Both the identifier field used for credential lookups and the principal resolver can be layered per guard, following the same override pattern as JWT config.

Identifier field

The credentials.identifier_field setting controls which column the basic guard uses when constructing the credential lookup from the HTTP Basic username:
config/authentication.php
'credentials' => [
    'identifier_field' => env('AUTHENTICATION_IDENTIFIER_FIELD', 'email'),
],
credentials.identifier_field
string
default:"email"
The app-wide default field name passed to the identity provider when looking up credentials from an HTTP Basic username. Override when your identity model keys off username, phone, key_id, or any other column.
.env
AUTHENTICATION_IDENTIFIER_FIELD=email
Any basic guard can override this app-wide default with an identifier_field entry directly in config/auth.php. This lets you register multiple Basic guards backed by different providers and different lookup columns in a single app:
config/auth.php
'guards' => [
    'cli' => [
        'driver'   => 'basic',
        'provider' => 'users',
        // identifier_field omitted — falls back to authentication.credentials.identifier_field (default 'email')
    ],
    'tenant_api' => [
        'driver'           => 'basic',
        'provider'         => 'tenant_api_keys',
        'identifier_field' => 'key_id',
    ],
],

'providers' => [
    'tenant_api_keys' => [
        'driver' => 'model',
        'model'  => App\Models\TenantApiKey::class,
    ],
],
The tenant_api guard looks up credentials against the key_id column on TenantApiKey, while cli uses the default email column on your user model.

Per-guard principal resolvers

By default, every guard resolves principals via the app-wide SineMacula\Laravel\Authentication\Contracts\PrincipalResolver container binding, which falls back to the package’s DefaultPrincipalResolver when no custom binding is registered. Any guard can override this with a principal_resolver entry in config/auth.php:
config/auth.php
'guards' => [
    'staff' => [
        'driver'             => 'jwt',
        'provider'           => 'users',
        'principal_resolver' => App\Auth\Resolvers\StaffPrincipalResolver::class,
    ],
    'customer' => [
        'driver'             => 'jwt',
        'provider'           => 'users',
        'principal_resolver' => App\Auth\Resolvers\CustomerPrincipalResolver::class,
    ],
],
Resolution precedence:
  1. auth.guards.<name>.principal_resolver — guard-local class, highest priority
  2. The app-wide PrincipalResolver::class container binding
  3. The package default DefaultPrincipalResolver
The per-guard resolver applies to both the bearer-token path and the JWT refresh exchange. When a guard declares a local resolver, both paths use the same resolver instance, so principal resolution is consistent regardless of whether the request is an initial bearer call or a refresh. To register an app-wide resolver for all guards that do not declare a local one, bind it in a service provider:
app/Providers/AppServiceProvider.php
use SineMacula\Laravel\Authentication\Contracts\PrincipalResolver;
use App\Auth\Resolvers\MyTenantScopedResolver;

public function register(): void
{
    $this->app->singleton(PrincipalResolver::class, MyTenantScopedResolver::class);
}