Deployment
lukk is built for a single first-party service that issues and verifies its own tokens. That's the default, and most apps never need anything else. But you can also split login off from your API, mint one token for several services, or hand a public key to an independent verifier. This page covers those topologies and the operational requirements they all share.
Everything here is server-side. The client's production concerns — the BFF session secret and throttling behind a proxy — are at the bottom of this page.
Server (Laravel)
Single service (default)
One Laravel app mints tokens at /auth/login and verifies them on every protected request. Issuer and audience are the same thing — your API — so they share a value:
LUKK_ISSUER=https://api.example.com
LUKK_AUDIENCE=https://api.example.comNothing else to configure. A browser SPA or a BFF in front of this API is not a verifier — it only holds and forwards the token — so it needs no lukk configuration of its own.
Same origin (front-end + API on one domain)
The simplest — and most common — setup: one domain (say example.com) serves the front-end and lukk's /auth/* and protected routes. Issuer and audience are just that domain:
LUKK_ISSUER=https://example.com
LUKK_AUDIENCE=https://example.com
LUKK_COOKIE_MODE=trueThis is less work than the split sub-domain case, not more:
- Cookie mode fits a direct browser SPA. The refresh token rides in the
__Host-refreshcookie (sent automatically on every same-origin request); the access token stays in memory and goes out as aBearerheader. (If you have a server-rendered/BFF layer, leavecookie_modeoff and use BFF mode instead.) - No CORS. Same origin means no preflight and no credentialed-CORS config — the cross-origin operational note below simply doesn't apply.
- CSRF is covered by design. The refresh cookie is
SameSite=Strict(a cross-site page can't trigger a refresh with it) and protected routes authenticate via theBearerheader, not an ambient cookie. - Passkeys are the easy case:
rp_id = example.com,origins = ["https://example.com"]— front-end and API share the exact origin.
lukk's own /auth/* routes always render JSON errors (it forces JSON on them); if the same app also serves web routes, your own auth:api routes need the same — see Installation.
Splitting auth and API
You can run a dedicated auth service (login, refresh, logout, 2FA, passkeys) and one or more API services that only verify tokens. This works today, with no code changes, as long as the services trust each other — see the caveat below. A browser client calling these services across origins uses the lukk-js direct transport mode.
On the auth service — the default setup. It keeps the routes, the database, and the refresh-token rotation.
On each API service — install lukk and configure it as verify-only:
// config/lukk.php
'routes' => false, // this service doesn't expose login/refresh/logoutLUKK_SECRET=… # the SAME secret as the auth service
LUKK_ISSUER=https://api.example.com
LUKK_AUDIENCE=https://api.example.comThen map the guard (Installation) and protect routes with auth:api as usual. An API service needs no database — no refresh_tokens, 2FA, or passkey tables — because it never mints or rotates anything. It only verifies the access token and resolves the user.
IMPORTANT
Point every service at a shared denylist cache (denylist_store → a Redis all of them reach). That's how a logout or a reuse-revocation on the auth service immediately stops tokens on the API services. Without it, a revoked token still works on an API service until it expires — bounded by access_ttl (15 minutes by default), but not instant.
WARNING
A note on trust. With the default HS256 algorithm, verifying a token requires the same secret that signs it — so every verifying service holds the signing key and could, in principle, mint tokens too. That's fine when the services are all yours and equally trusted. If a verifier must not be able to mint (a different trust domain, or least-privilege isolation), use asymmetric keys instead.
Multiple services (audiences)
The aud claim says who a token is for. LUKK_AUDIENCE is comma-separated, so the auth service that mints the token lists every service it's for:
# on the auth service — mint one token for both
LUKK_AUDIENCE=https://api.example.com,https://billing.example.comEach service sets its own audience and accepts the token when it's in the list:
# on the billing service
LUKK_AUDIENCE=https://billing.example.comSo a service's LUKK_AUDIENCE is exactly the set of audiences it accepts. A service whose audience isn't listed rejects the token — this is what stops a token meant for one service from being replayed against another. A single-audience token (the default) stays a plain string; only multi-audience tokens become an array.
Asymmetric keys (RS256 / ES256)
When a verifier must be able to check tokens but never mint them — a separate trust domain, or least-privilege isolation — a shared symmetric secret is the wrong tool. You want the verifier to hold only a public key, which means RS256/ES256 + a JWKS endpoint.
This is built in. Generate a signing keypair on the auth service:
php artisan lukk:keygenSet the algorithm and point lukk at the keypair:
LUKK_ALGORITHM=RS256 # or ES256The auth service now exposes GET /auth/jwks — a cacheable JWK Set of public keys only. Each verify-only API runs the same algorithm with just the public key in its keys.public and no private key, so it can validate tokens but never sign them:
# on each verify-only API service
LUKK_ALGORITHM=RS256 # match the auth service
# keys.public holds the public key only — no private keyA lukk verifier reads that public key from its own config; it does not fetch a remote JWKS. The /auth/jwks endpoint is where you publish the key — copy it into the verify-only service's config, or point a non-lukk consumer (an API gateway, another framework) at the URL. Keys are addressed by kid, so you can rotate the signing key without forcing logouts: publish the new key alongside the old, migrate signing, then retire the old kid once its tokens have expired.
The alg is pinned from config and is never read from the token header — the alg-confusion defense. See Configuration for the key settings and Architecture for the design rationale.
Pruning expired tokens
Expired and revoked refresh-token rows accumulate over time. The lukk:prune command deletes them:
php artisan lukk:pruneThe package schedules this command to run daily by default. To take over scheduling, opt out from a service provider's boot method and register your own cadence:
use Illuminate\Support\Facades\Schedule;
use Lukk\Lukk;
public function boot(): void
{
Lukk::disableScheduling();
Schedule::command('lukk:prune')->hourly();
}Only the auth service (the one holding the refresh_tokens table) prunes; verify-only API services have no database to prune.
Operational requirements
A few environment concerns that the package can't enforce for you but depends on:
- HTTPS everywhere. Access tokens are bearer credentials and refresh cookies are
Secure— serve every issuing and verifying route over TLS. The hardened__Host-cookies lukk sets are only accepted by browsers over HTTPS. (Running plain HTTP locally has its own caveats — see Local Development.) - Trusted proxies. Every rate limit (login, refresh, passkeys, 2FA) keys on
$request->ip(). Behind a load balancer or reverse proxy you must configure Laravel'sTrustProxiesto your actual proxy IPs. If it trusts arbitrary clients, an attacker spoofsX-Forwarded-Forto mint a fresh throttle bucket per request and defeats all rate limiting. - Throttling behind a BFF. A back-end-for-frontend (e.g.
lukk-nuxtin BFF mode) sends every user's auth traffic from the BFF server's single IP, so the per-IP login/refresh/2FA/passkey limits collapse onto one bucket for your whole user base. Either raise those limits substantially for a BFF deployment, or have the BFF forwardX-Forwarded-For(withTrustProxiesset) so lukk throttles per real client. - Shared revocation store. The denylist, the TOTP replay cache, and the passkey/2FA throttles all live in the cache (
denylist_store). Across multiple nodes this must be a shared, persistent store (e.g. Redis) — never thearraydriver and never a per-node cache, or a revoked token can still be accepted on another node and replay protection isn't authoritative. - CSRF in cookie mode. The refresh cookie is
SameSite=Strict(set by lukk). If your front-end is on a different site from the API, also ensure CORS is locked to the exact front-end origin with credentials — neverAccess-Control-Allow-Origin: *withsupports_credentials. - JSON error responses. lukk's
/auth/*routes return401/422JSON on their own (it forcesAccept: application/json), immune to your exception config. Your ownauth:apiroutes are not — attach thelukk.force-jsonmiddleware (Route::middleware(['lukk.force-json', 'auth:api'])) or see Installation for the alternatives (don't rely onshouldRenderJsonWhenalone; it doesn't cover the guest redirect).
Run through the full security checklist before you ship.
Client (Nuxt) in production
The client is mostly configuration you set once (Installation, Configuration); two things matter specifically at deploy time, both in BFF mode:
- The session secret must be identical across every instance.
NUXT_LUKK_SESSION_PASSWORDis the confidentiality boundary for the sealed session cookie — the BFF equivalent ofAPP_KEY. A load-balanced deploy with per-instance secrets silently invalidates sessions, and rotating it logs everyone out. See Configuration → the session password. - Throttling collapses onto the BFF's IP. Every user's auth traffic egresses from the BFF server, so lukk's per-IP throttles see one address — raise the limits or forward
X-Forwarded-For(this mirrors the server note above). Keep lukk'sgrace_seconds > 0so the proxy's single-flight refresh never trips a false family revocation.
In direct mode there's nothing server-side to run, so neither concern applies — it's the only option for a fully static (SSG) deploy. See Transport Modes for the trade-off, and Local Development for the HTTPS-cookie caveat when running the client locally.
Next: Local Development