Fail-closed defaults
An empty or invalid JWT secret causes the guard to throwInvalidJwtConfigurationException 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 guardaud— audience must match the configured audience for the guardtyp— 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 leewayleeway— clock-skew tolerance is explicit and bounded; it does not disable expiry checking
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
pidclaim is present and the principal resolver cannot return a matching principal, the request is rejected. - If a
didclaim 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.
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 aRefreshFailed 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’sTimebox. 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 thedevices table — blocks refresh immediately. Any subsequent refresh attempt for that device will fail with DEVICE_REVOKED and fire a RefreshFailed event.
Access-only mode security note
When running in access-only mode (nodid 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()returnsfalse, the request is rejected. - If the principal resolver returns
nullor the principal’sisActive()returnsfalse, the request is rejected. - If the
pidordidclaim does not resolve, the request is rejected.