Skip to main content
The package is built around a fail-closed philosophy: misconfiguration raises an exception at resolution time rather than silently degrading to an insecure state. This page documents the specific security properties the package enforces, where you must supply additional controls, and the caveats that apply to access-only deployments.

Fail-closed defaults

An empty or invalid JWT secret causes the guard to throw InvalidJwtConfigurationException the first time it is resolved — not at boot time, and not silently. There is no fallback to an insecure mode, no warning-and-continue, and no way to configure a guard that accepts forged tokens when signing material is missing. The same applies to kid mode: an active_kid that is not present in the keys map, an empty keys map, or any entry in the map whose secret is an empty string all throw at keyring construction time. Every signing-material boundary is fail-closed regardless of configuration depth.

JWT pipeline hardening

Every token parse enforces the full claim set:
  • iss — issuer must match the configured issuer for the guard
  • aud — audience must match the configured audience for the guard
  • typ — token type must match the expected type for the path (access token on bearer, refresh token on refresh exchange)
  • exp — token must not be expired, subject to the configured leeway
  • leeway — clock-skew tolerance is explicit and bounded; it does not disable expiry checking
The typ claim prevents type-confusion attacks where a refresh token is submitted on the bearer path or vice versa. A refresh token presented to the bearer path is rejected because its typ does not match the expected access-token type, even if it is otherwise cryptographically valid. Every issued token embeds a per-token jti (JWT ID). The jti is carried through the refresh exchange but is not consulted on the bearer path — see the access-only mode section below.

Fail-closed pid and did claims

A token that carries a pid (principal ID) or did (device ID) claim in its payload is not allowed to silently downgrade to a no-principal or no-device state if resolution fails.
  • If a pid claim is present and the principal resolver cannot return a matching principal, the request is rejected.
  • If a did claim is present and the device record cannot be loaded, the request is rejected.
  • Neither claim degrades gracefully to null. The guard never silently strips a claim and proceeds as if it were absent.
This prevents a class of attack where a tampered or replayed token with a mismatched pid causes the guard to fall back to a default principal the identity was not authorized to act as.

Refresh-token replay detection

Refresh-token rotation uses an atomic per-device compare-and-swap on the stored rotation digest. The rotation digest is a constant-time hash of the issued refresh token; it is updated atomically when a new token pair is issued. If two requests attempt to use the same refresh token concurrently — or if an attacker replays a previously consumed refresh token — the CAS fails. The device is immediately revoked and a RefreshFailed event is dispatched with reason ROTATION_REUSE. Once revoked, no further refresh exchanges are possible for that device; the user must re-authenticate from scratch. This property means that a compromised refresh token can be detected and neutralized the moment the legitimate client next attempts to refresh, because the attacker’s use of the token will have invalidated the client’s copy and vice versa.

Constant-time credential validation

The HTTP Basic guard wraps the entire credential validation pipeline — provider lookup, password hash comparison, active-state check, and principal resolution — inside Laravel’s Timebox. The minimum elapsed wall-clock time is configurable via timebox.credentials_microseconds (default 400 ms). The timebox ensures that responses arrive in approximately constant time regardless of whether the username exists in the database. Without this control, an attacker could distinguish valid usernames from invalid ones by measuring response latency — a user-enumeration side-channel. The credentials_microseconds value must exceed the worst-case cost of your configured hasher under load. If you increase bcrypt rounds or switch to Argon2, re-measure and raise the limit accordingly.
The resolution cache, when enabled, only short-circuits the bearer identity provider lookup — the database query that fetches the identity row. Active-state checks (CanBeActive::isActive()), pid claim matching, and device validation always run live against the resolved model on every request. The refresh path never uses the cache at all: revocation and replay detection are device-backed and immediate. Enabling the cache does not weaken any of these checks; it only affects how the identity row itself is fetched.

Revoking access

Revoking a device — by deleting or flagging its row in the devices table — blocks refresh immediately. Any subsequent refresh attempt for that device will fail with DEVICE_REVOKED and fire a RefreshFailed event.
Revoking a device does not invalidate already-issued access tokens. Access tokens are self-verifying JWTs: the guard validates them against their signature and claims without consulting any server-side store. A revoked device’s access tokens remain valid until their exp claim passes. For sensitive contexts, keep access_ttl_minutes short (15 minutes or less) so the revocation window is bounded. If you need immediate invalidation, you must implement a token blocklist at the application layer — the package does not ship one.

Access-only mode security note

When running in access-only mode (no did claim, no refresh, no device tracking), jti values embedded in access tokens are not consulted on the bearer path. There is no server-side lookup of jti values — revocation can only occur through identity, principal, or device rehydration failure. Specifically:
  • If the identity’s isActive() returns false, the request is rejected.
  • If the principal resolver returns null or the principal’s isActive() returns false, the request is rejected.
  • If the pid or did claim does not resolve, the request is rejected.
Outside of those conditions, a token that is cryptographically valid and has not expired will authenticate successfully, regardless of any application-level state changes that occurred after the token was issued.