Skip to content

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

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

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

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

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

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

php
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

ContractDefaultResponsibility
TokenIssuerFirebaseTokenIssuerMints access tokens.
TokenVerifierFirebaseTokenVerifierVerifies access tokens and checks the denylist.
RefreshTokenRepositoryDatabaseRefreshTokenRepositoryPersists refresh tokens and families.
DenylistCacheDenylistRecords and checks revoked jti/fid values.
LoginResponse / RefreshResponse / LogoutResponsebuilt-inShape the HTTP responses.
TwoFactorChallengeResponsebuilt-inShapes the 2FA login challenge.
TwoFactorProviderGoogle2FaTotpProviderGenerates and verifies TOTP codes.
WebAuthnCeremonySpomkyWebAuthnCeremonyPerforms WebAuthn registration/assertion.
PasskeyRepositoryDatabasePasskeyRepositoryPersists 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.

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