Customization
lukk follows the Sanctum pattern: every moving part is either a contract bound to a default (rebind it in a service provider) or a closure hook on the static Lukk class (register it from a service provider's boot method). You never edit the package. This page is server-focused.
The Lukk hub
The Lukk class is a static configuration hub, like Sanctum. Register hooks from the boot method of a service provider (for example App\Providers\AppServiceProvider):
use Lukk\Lukk;
public function boot(): void
{
Lukk::authenticateUsing(/* ... */);
Lukk::tokenClaimsUsing(/* ... */);
Lukk::useRefreshTokenModel(/* ... */);
}Lukk::actingAs() (for authenticating a user in your own tests) and Lukk::disableScheduling() (to take over the lukk:prune cadence — see Deployment) live on the same hub.
Custom login logic
By default lukk validates the email and password against your configured user provider. To take full control — extra conditions, a different credential field, a "must be active" check — pass a closure to authenticateUsing. Return the authenticated user, or null to reject:
use Illuminate\Http\Request;
use Lukk\Lukk;
Lukk::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->input('email'))->first();
if ($user && Hash::check($request->input('password'), $user->password) && $user->is_active) {
return $user;
}
return null;
});WARNING
If your callback authenticates on a different field, set lukk.username to match it. The login throttle and the account lockout both key on that field — with a mismatch the lockout never sees an identifier to count and silently does nothing.
The login throttle still wraps your closure — failed attempts are rate-limited exactly as on the default path. Constant-time behaviour, however, becomes your responsibility: the package's unknown-user timing equalizer only runs on the built-in email/password path, so a closure that does User::where(...)->first() and hashes only when the user exists leaks a user-enumeration timing oracle. Make your closure take the same time whether or not the account exists — e.g. always run a Hash::check against a dummy hash when no user is found.
Custom token claims
Add custom claims — roles, a tenant id, anything your API needs — to every access token. The closure receives the user id and returns an array of claims:
use Lukk\Lukk;
Lukk::tokenClaimsUsing(fn ($userId) => [
'roles' => User::find($userId)->roles->pluck('name'),
]);NOTE
Your claims are merged in, but the standard claims (sub, exp, iss, aud, jti, fid, …) always win and cannot be overridden.
Swapping the refresh token model
To use your own Eloquent model for refresh tokens (to add columns, relationships, or scopes), extend the base model and register it — the Sanctum approach:
use Lukk\Lukk;
use App\Models\RefreshToken;
Lukk::useRefreshTokenModel(RefreshToken::class);Swapping storage
Refresh-token storage sits behind Contracts\RefreshTokenRepository, separate from the rotation policy (which lives in Actions\RotateRefreshToken). To move storage from the database to Redis, bind your own implementation — the policy is untouched:
use Lukk\Contracts\RefreshTokenRepository;
use App\Auth\RedisRefreshTokenRepository;
$this->app->bind(RefreshTokenRepository::class, RedisRefreshTokenRepository::class);Reshaping responses
The login, refresh, and logout responses are Responsable contracts. Rebind one to change the body shape, add headers, or switch between JSON and cookies:
use Lukk\Contracts\LoginResponse;
use App\Http\Responses\MyLoginResponse;
$this->app->bind(LoginResponse::class, MyLoginResponse::class);The response contracts are LoginResponse, RefreshResponse, LogoutResponse, and TwoFactorChallengeResponse.
NOTE
The default response shape is the contract the lukk-js clients consume. If you reshape it, keep the client in sync (or adapt it) so the two don't drift — see Authentication and Using lukk-core.
Swapping the issuer, verifier, or denylist
The cryptographic and revocation seams are contracts too. Rebind Contracts\TokenIssuer or Contracts\TokenVerifier to change how tokens are minted or validated (for example to move to RS256 — though that's built in; see Deployment → Asymmetric keys), or Contracts\Denylist to back revocation with something other than the cache.
Available contracts
| Contract | Default | Responsibility |
|---|---|---|
TokenIssuer | FirebaseTokenIssuer | Mints access tokens. |
TokenVerifier | FirebaseTokenVerifier | Verifies access tokens and checks the denylist. |
RefreshTokenRepository | DatabaseRefreshTokenRepository | Persists refresh tokens and families. |
Denylist | CacheDenylist | Records and checks revoked jti/fid values. |
LoginResponse / RefreshResponse / LogoutResponse | built-in | Shape the HTTP responses. |
TwoFactorChallengeResponse | built-in | Shapes the 2FA login challenge. |
TwoFactorProvider | Google2FaTotpProvider | Generates and verifies TOTP codes. |
WebAuthnCeremony | SpomkyWebAuthnCeremony | Performs WebAuthn registration/assertion. |
PasskeyRepository | DatabasePasskeyRepository | Persists passkey credentials. |
Replacing PasskeyRepository or RefreshTokenRepository under multiple guards
Both are guard-scoped: an account is (guard, id), not id, because providers are separate tables where users.id === admins.id is the ordinary case. Every method must honour that — including findByCredentialId, which takes a credential id and no user and is therefore the authentication decision itself.
The single exception is PasskeyRepository::existsByCredentialId(), which must be unscoped. credential_id is globally unique (WebAuthn requires it), so registration has to ask whether any guard holds an id before writing one; asking the scoped question instead lets a cross-guard duplicate reach the database constraint as a 500 rather than a clean validation error.
Bind a replacement with bind, not singleton — the active guard is per-request, and a memoized instance carries the previous request's guard into the next one.
That's the whole customization surface. For the design rationale behind these seams, see Architecture; for the events lukk fires at the security-relevant moments, see Events. Questions or contributions are welcome on the lukk and lukk-js repositories.
Lukk::rateLimitKeyUsing()
Replace the identity every lukk throttle buckets on. The default is the caller's address, with IPv6 masked to rate_limits.ipv6_prefix; override it when the source address isn't the right bucket — a shared API gateway, a tenant, a CDN's own visitor token.
use Illuminate\Http\Request;
use Lukk\Lukk;
Lukk::rateLimitKeyUsing(fn (Request $request) => 'tenant-'.$request->user()?->tenant_id);The callback runs on every throttled request, so keep it cheap. The value must be unforgeable — it also buckets the login limiter, so a spoofable header would let an attacker mint a fresh bucket per request — and it is used verbatim as part of a cache key, so namespace anything untrusted. An empty return falls back to the address. Under a long-running worker (Octane) the closure outlives the request that registered it: derive everything from the $request argument, never capture it.