Upgrading
Both halves of lukk are pre-1.0 (0.x). Per SemVer §4, a minor bump (0.x.0) may carry a breaking change; a patch bump (0.x.y) never does. The 1.0 releases will mark API/schema stability and end this cadence.
Each repo keeps a version-specific UPGRADE.md — the authoritative, "you may need to do something" subset of its changelog, organized highest-version-first with High / Medium / Low impact tags. Read it (and the changelog) before bumping:
- lukk (Laravel package) — UPGRADE.md · CHANGELOG
- lukk-js (TypeScript / Nuxt client) — UPGRADE.md ·
lukk-corechangelog ·lukk-nuxtchangelog
Ground rules
- Pin an exact version. Don't float
^on a0.xdependency you can't retest. - Upgrade the server first. lukk's HTTP contract is the source of truth; the client only speaks it. When a server change needs a matching client change, its
UPGRADE.mdentry says so. - Schema changes don't auto-apply. lukk's migrations are publish-only, so a new column reaches your database only when you add it, and new features are config-gated. Changes to how existing routes behave do apply on upgrade — the entries below say which, and what to check.
- Run your tests after bumping, then read the entries at or below your target version.
Highest-impact change right now
WARNING
lukk 0.7.0 reworks POST /auth/logout. It is no longer behind auth:{guard}: a valid access token or the client's refresh token ends the session, so an idle tab whose access token has lapsed can finally log out instead of getting a 401. See Logging out.
What can surprise an existing install:
- A request with no usable credential answers
204, not401, and revokes nothing. A test using logout as a "does this token authenticate on this guard" probe needs a realauth:{guard}route. - With only a refresh token, nobody is authenticated —
$request->user()isnullwhere the route would previously have answered401and never reached your response. TheAuthenticatemiddleware no longer runs here, so anything attached through it doesn't apply either. - Cookie mode: the refresh cookie is used, and the clearing
Set-Cookiesent, only onContent-Type: application/jsonorSec-Fetch-Site: same-origin/none. A client that POSTs with no body must now send{}as JSON (lukk-js does). LogoutResponsetakesbool $clearRefreshCookie. A rebound implementation must honour it — ignoring it re-opens a forced-logout CSRF. AndDELETE /auth/sessions/othersno longer goes through that contract.- The route has no throttle, but the refresh-token lookup does, and a throttled one answers
429withRetry-Afterrather than a204the client would read as "logged out". - A body
refresh_tokenis read from a JSON body only. A consumed refresh token presented past the grace window dispatchesRefreshTokenReusedand revokes its family, exactly as/refreshdoes.
The full list is in lukk UPGRADE.md, including the constructor changes for anyone extending the controllers: AuthenticatedSessionController takes EndSession instead of RevokeSession and a trailing ConfirmPasswordUnchanged, and its destroy() no longer takes a TokenVerifier; TwoFactorChallengedSessionController gains ChallengeToken and ConfirmPasswordUnchanged.
WARNING
lukk 0.7.0 changes the amr claim and how a passkey is trusted. High impact if anything reads amr — a gateway, a resource server, or your own gates.
amruses only RFC 8176 values. A passkey is["pop","user"], plus"mfa"when the assertion was multi-factor; password + TOTP is["pwd","otp","mfa"]; a single-factor passkey + TOTP is["pop","user","otp"], withoutmfa.webauthnis gone: replace a check for it withpop, and "was this multi-factor" withmfa.amrnow survives refresh, stored in a newrefresh_tokens.amrcolumn. The column went into the existing core migration, so a fresh install gets it. An install that already ran that migration adds it with a migration of its own — republishing doesn't help, the copy gets a new timestamp and fails with "table already exists". Until then nothing breaks for readingamr: the claim just disappears after the first refresh as before. (But see the password step-up below.)phpSchema::table('refresh_tokens', function (Blueprint $table) { $table->text('amr')->nullable(); });A custom
RefreshTokenRepository(or a subclass overridingpersist()) must add the trailing?string $amr = nullargument and round-trip it.amr— and the newauth_timeclaim — are now reserved: atokenClaimsUsinghook can neither forge nor erase them.A passkey stands in for an enrolled TOTP only when the assertion was multi-factor — UV flag on a credential already
uvInitialized, decided per assertion, no longer byuser_verification. OtherwisePOST /auth/passkeys/loginanswers{"two_factor": true, "challenge_token": "…"}like a password sign-in, and your client must complete it at/auth/two-factor-challenge(lukk-js 0.12 does).A custom
WebAuthnCeremonymust adopt the new signatures:verifyAssertion()returns aPasskeyAssertionand takes?string $userVerificationandint|string|null $owner(the expected user handle on a step-up);authenticationOptions()takes$userVerification; and there is a newisMultiFactor(). A customPasskeyRepositorymust returnboolfrom a forward-onlyupdateSignCount(), and answer an empty store without throwing, because erasure, export andrevoke_passkeysnow call whatever is bound. A mismatched method is a fatal on the first request that resolves the binding, not at boot.Passkey step-up must meet the account's highest available level. Use the new
POST /auth/confirm-passkey/options; a refused step-up is422oncredential, not403.Password step-up is refused with
422onpasswordon an account with a second factor the guard enforces, or a user-verifying passkey, unless the session's token carriesmfainamrand itsauth_timeis within the newconfirm.mfa_max_age(one day). A token with noamrcounts as single-factor — so migrate theamrcolumn, or such users must confirm with a passkey or sign in again after every refresh. Anauth_timemore thanleewayin the future, or not a timestamp at all, counts as no recent sign-in (a fractional one counts as the second it falls in).auth_timeis stored with the session, in theamrcolumn beside the methods ({"amr":[…],"auth_time":…}), so a session signed in before the upgrade has noauth_timeafter its next refresh and needs a fresh multi-factor sign-in before it can step up with a password. A customRefreshTokenRepositorymust treat$amras opaque and hand it back unchanged. Details.Cookie mode: token routes require JSON.
login,register,refresh,two-factor-challengeandpasskeys/loginanswer415unless the request has a JSON body orSec-Fetch-Site: same-origin/none(login CSRF, RFC 6265bis §5.7). Send{}as JSON for a cookie refresh; lukk-js already does. Body mode is unchanged — except that its/refreshno longer reads a leftover refresh cookie at all. Details.Under bcrypt, a password over 72 bytes is refused — a
422on register, reset and change-password (lukk adds the check after aregisterValidation()rule set, or a reboundRegisterRequestsubclass'srules(), too — with aregisterUsing()hook only the string and NUL-byte checks, not the 72-byte rule, which is bcrypt's). Sign-in, step-up and change-password's current password refuse one against a bcrypt hash ($2a$/$2b$/$2x$/$2y$), which lukk could verify only in part — and every earlier lukk release stored such passwords, truncated. Those users are typing the right password: they get a422saying "This password is longer than 72 bytes, which can no longer be checked in full. Reset your password to continue." and must reset. You can't find them in the database (the hash doesn't record the length), so consider telling your users before you upgrade. See Password policy.
lukk 0.7.0's smaller changes, each with what to check:
| Change | Impact | What to do |
|---|---|---|
The default guard must resolve users from the provider sign-in checks against: auth.guards.<default>.provider (or, naming none, auth.defaults.provider) must read the same users as lukk.user_provider, or boot throws naming both. Two provider names over the same users boot: the same model under the eloquent driver, the same table and connection under database, or — for any other driver — identical auth.providers entries. | High if they read different users | Point both at the same provider. Otherwise a password from one table signed in as the row with the same id in the other. The default (users on both) is unaffected. Details. |
Sign-in, registration, forgot- and reset-password refuse an identifier carrying a control, format or enclosing character, U+034F, or whitespace other than the ASCII space with a 422; registration's name refuses NUL. ZWNJ, ZWJ and U+20E3 COMBINING ENCLOSING KEYCAP are accepted. The login-throttle fold strips those characters (and combining marks, ZWNJ, ZWJ and the keycap) and trims Unicode whitespace. | Low | Find accounts whose stored identifier contains a refused character before upgrading — they can no longer type it to sign in (how). Buckets for such identifiers restart once, and a held idn: lock for one is no longer reached by it. |
With Lukk::authenticateUsing() reading a field other than lukk.username, the IP-independent login bucket is skipped (an empty identifier was one bucket for everybody) and the per-address bucket keys on the address alone. | Low | Point lukk.username at your field to keep the account cap. |
The lockout still reserves an attempt before checking it, but locks — and fires AccountLocked — only once the check failed. The attempt that reaches the cap with the right credential signs in and fires neither AccountLocked nor AccountReleased. New optional contract Lukk\Contracts\ReservesLockoutAttempts. | Low — only if you implement LockoutRepository or listen for lockout events | A custom store keeps the old behaviour through recordFailure(); implement reserve() and lockIfSpent() to get the new one (contract). |
The two-factor per-account limiter counts an attempt before checking it, so a concurrent burst gets rate_limits.two_factor.max_attempts guesses and the rest a 429 on code. | Low | None. |
With block_unverified_login on, an unverified account's two-factor challenge is refused (403) before the factor is checked, so no recovery code is spent on it. | Low | None. |
RecoveryCodeUsed fires when a recovery code is spent ($user, $remaining). | Low | Listen for it to notify the user and prompt new codes (NIST SP 800-63B-4 §4.2). |
Refresh-token writes use saveQuietly(): model events (creating/created/saving/saved) no longer fire for rows lukk stores, a listener can't cancel one, and persist() throws if nothing was written. | Low — only if you observe a useRefreshTokenModel model | Audit sessions from lukk's events instead (RefreshTokenReused, SessionUnclaimed, your own login/logout listeners). A column filled from creating (a tenant trait's tenant_id) is no longer filled — on a NOT NULL column every sign-in and refresh then fails; fill it as the model is built. |
The export gains password_reset, two_factor.pending, two_factor.recovery_codes_remaining, and per session amr and authenticated_at. ExportAccount takes the broker name as a trailing optional argument. | Low | None, unless you build ExportAccount yourself. |
A lukk-jwt guard answers 401 with no query for a sub the model's key certainly cannot hold — for exactly EloquentUserProvider whose auth identifier is the key: the stock auto-incrementing integer id, or a HasUuids/HasUlids key judged by the model's own isValidUniqueId() — and for any sub containing a NUL byte. A non-UUID fid names no family (no PostgreSQL 500). | Low unless a co-issuer mints tokens | Co-issuers must mint a sub your identifier column holds: for every other shape (Snowflake keys, a non-key identifier, a legacy 32-bit increments('id') past int4, EloquentUserProvider subclasses, custom providers) a subject the column refuses stays a reported PostgreSQL 500, by design. Details. |
DELETE /auth/sessions/others answers 409 for a token whose fid is empty, as for one without fid; DELETE /auth/sessions also denylists the presenting token when it names no family of lukk's. | Low | None. Before, an empty fid logged out every device, and logout-all left a co-issuer's bearer live. |
A failed sign-in takes at least login_timebox_micros (500 ms); every forgot-password and every failed reset-password at least password_reset.timebox_micros (one second). A successful sign-in or reset returns early. | Low | Raise either if your slowest real path takes longer — the box pads, it never shortens. Queue the reset notification. |
leeway is clamped to 0–300 s for every token lukk decodes. | Low | A larger LUKK_LEEWAY now acts as 300; fix the clock skew instead. |
Input that is not valid UTF-8 is a 422, never a 500 on PostgreSQL: the sign-in identifier, a passkey's credential.id (sign-in and step-up), a passkey name, a registration name, and a non-email lukk.username. A password is left alone — it never reaches SQL, and refusing one would lock out a password an earlier release stored. | Low | None. |
passkeys.challenge_ttl defaults to 300 (was 120), is sent as the options' timeout, and the server keeps the challenge 30 s longer. | Low | None; an explicit LUKK_PASSKEY_CHALLENGE_TTL is kept. |
ceremony_id on passkey login and step-up must be the UUID lukk issued. | Low | A custom client sending anything else gets 422 on ceremony_id. |
Unclaimed sessions (claim_seconds, off) and POST /auth/session/claim are new. | Low | Nothing until you set the key. A custom RefreshTokenRepository should populate RefreshTokenRecord::$original. |
With block_unverified_login on, an unverified account with two-factor is refused (403) before a challenge is issued, and at redemption. | Medium | None — the gate now applies to the two-factor path too. It and features.email_verification are read per guard. |
DELETE /auth/sessions/others answers a bare 204 (no LogoutResponse, no cookie cleared), and 409 for a bearer with no fid. | Low | A rebound LogoutResponse no longer shapes this route. Co-issuer clients should call DELETE /auth/sessions. |
features.logout_all is honoured; rotation / reuse_detection / denylist are removed (never read). | Medium if you set logout_all to false | false now removes DELETE /auth/sessions (a 404). Delete the three dead keys. |
LockoutRepository gained summariesForSubjects(array $subjects, ?string $guard): array, and erasure and export now call a rebound one whether or not a lukk_lockouts table exists. | Medium if you implement it | Mirror forget(): reach exactly the rows it deletes, scoped to the guard. Answer 0 and [] for nothing stored. The export gains lockouts. |
FinishPasskeyLogin no longer clears the password-confirm lockout itself. | Medium if you call it from your own code | Call releaseConfirmLock($passkey) once you have granted what the assertion was for — and for a sign-in only on a multi-factor assertion. lukk's own routes already do. |
A success clears only the per-address login bucket — the IP-independent account_max_attempts bucket decays instead of resetting — and non-account lockout subjects are stored as idn:sha256:<digest>. | Low | None, but a user who mistypes often may now meet account_max_attempts after a successful sign-in where before they would not. Old locks keep their key and are still found by lukk:release, erasure and the export. |
Per-guard features.lockout, lockout.*, rate_limits.login.* and rate_limits.two_factor.* are honoured. | Low | Check that an extra guard's overrides are what you want enforced — they now are. |
New password_reset.revoke_passkeys (off). | Low | Keep revoke_sessions on with it. |
Reset and change-password write the model's getAuthPasswordName() column. | Low | Remove any password mutator you added to work around the 500. |
lukk.username must be a plain column name not containing password; in debug, one naming no column is refused. | Low | Rename a dotted name that only worked under authenticateUsing. |
lukk:secret --guard refuses a name that isn't letters, digits, _ or -. | Low | None. |
| Authenticator events are new. | Low | Listen and notify the user out of band (NIST SP 800-63B-4 §4.1.2.1). |
LogoutResponse clears the cookie per guard and takes bool $clearRefreshCookie. | Low | A rebound one must honour the flag — see the logout warning above. |
Refresh rotation, bulk revocation and recovery-code redemption open their transaction on the model's own connection; erasure checks the broker's reset table on auth.passwords.<broker>.connection. | Low | None. If a useRefreshTokenModel model or your user model sits on another connection, rotation and redemption were not atomic before — upgrade. |
Confirm-password and change-password answer 422, not 500, when the stored hash is one the hasher refuses (another algorithm, a $2b$ import with hashing.bcrypt.verify on). | Low | None — sign-in already failed that closed. |
Account erasure opens a transaction on every connection it writes to — the default one, the password broker's and the refresh-token store's — and auth.passwords.<broker>.connection may be an enum, backed or pure. | Low | None. With a useRefreshTokenModel model on another connection, a failed erasure used to keep the user and lose their credentials — upgrade. |
Access tokens whose exp, iat or nbf is not a timestamp between the epoch and the end of year 9999 are rejected; fractions and numeric strings stay valid. Challenge tokens take the same upper bound on exp. A token whose aud is a JSON object is refused too (RFC 7519 §4.1.3). | Low unless a co-issuer mints tokens | Check that a co-issuer stamps real Unix times and a string or array aud — a token it used to get through, only to 500 later, is now a 401. |
A recovery code stored under a hasher the app has since left is no match, not a 500. | Low | After switching hashers with verify on, have users regenerate their codes. |
Registration, reset and change-password require the new password to be a string under every rule set, and registration's safety net (string, NUL, 72-byte) also runs for a rebound RegisterRequest subclass that overrides rules(). A sign-in identifier that is not valid UTF-8 is a 422 before any lookup (the password is not checked for it). | Low | None — a JSON number or array password is a 422 on password. |
An auth_time may be fractional (floored) or a numeric string; a negative one is refused; confirm.mfa_max_age may be a float, so a "never" past the integer range no longer 500s, and a non-numeric one reads as 0 (the multi-factor allowance off). | Low | None. |
POST /auth/password on an account with an enforced TOTP or a user-verifying passkey requires a recent multi-factor session (amr carries mfa, signed in within confirm.mfa_max_age), as a password step-up does; otherwise 422 on current_password, before anything is checked. | Medium if users change passwords from password-only sessions | Have the user sign in again with their second factor first. Accounts with nothing stronger than a password are unaffected. |
Two-factor challenges carry pwf, a fingerprint of the password (an HMAC of the stored hash under app.key), and one whose password changed — or that carries none — is refused at redemption with a 422 on challenge_token, before the code is checked, so nothing is spent. | Medium in the minutes around the deploy, and for co-issuers | A challenge minted by 0.6 and redeemed after the upgrade is refused (the user signs in again), and so is a co-issuer's. Deployments that redeem each other's challenges must share APP_KEY (and app.previous_keys across a rotation). |
A malformed lukk.guards — a numeric guard name (there or as lukk.guard), a list of names, a truthy scalar — throws at boot naming the problem. | Medium if your config has one | Fix the config. false or '' still mean "no extra guards". |
A per-guard features.two_factor that is null or '' (an unset env()) now challenges enrolled accounts, and the challenge route mounts on the same rule. | Medium if an extra guard sets it from an unset env() | Set the flag explicitly (false) if you meant "off". |
POST /auth/two-factor/confirm on an account whose two-factor is already confirmed, and POST /auth/two-factor/recovery-codes on one with no two-factor (confirmed or pending), answer 409 instead of succeeding. | Low | None — neither changed anything a user needed. lukk-js passes them through as plain errors. |
The export's passkeys[] entries are credential_id, name, created_at, last_used_at, aaguid, transports, and every time is ISO-8601 — last_used_at was a unix integer. | Medium if you parse the export | Parse the string. A rebound PasskeyRepository may return created_at (unix int), aaguid and transports from summariesForUser(); one that doesn't exports null. |
Per-guard two_factor.challenge_ttl, confirm.ttl and confirm.header are honoured. | Low | A guard with its own confirm.header needs its clients to send the confirmation there. |
Cookie mode: the refresh cookie's Max-Age is what is left of the session's absolute expiry, not a fresh refresh_ttl per rotation. TokenPair gained an optional refreshExpiresAt. | Low | None; code building a TokenPair itself can leave it out (the cookie then gets the full refresh_ttl). |
A negative leeway counts as zero everywhere — the denylist TTLs and the unclaimed-session window use the same clamped value as the verifier. | Low | None. |
AccountLocked can fire twice for one run (the identifier's fold and the account); AccountReleased fires only when a held lock is released. | Low if you notify or audit on them | Mail the owner only for id: subjects. A rebound LockoutRepository should fire AccountReleased for a held lock only. |
PasswordChanged and the authenticator events (TwoFactorAuthenticationConfirmed, PasskeyRegistered, …) use SerializesModels: a queued listener gets the user re-read when the job runs. | Low if you queue those listeners | A user deleted before then fails the job with ModelNotFoundException; copy what you need when queuing. |
An explicit null lukk.path (an unset env()) mounts the routes at auth, not at the root. | Low | Set the path you want explicitly. |
See lukk UPGRADE.md for the full text of each.
Finding identifiers sign-in now refuses
An account whose stored identifier carries a character lukk 0.7.0 refuses can no longer type it to sign in, so find those accounts before upgrading. The authoritative check is the pattern lukk validates with, run in PHP — swap in your table and lukk.username column:
use Illuminate\Support\Facades\DB;
use Lukk\Support\Identifier;
DB::table('users')->select(['id', 'email'])->lazyById()->each(function ($row) {
if (preg_match(Identifier::INVISIBLE, (string) $row->email) === 1) {
echo $row->id, PHP_EOL;
}
});The SQL below matches the same set as PCRE2 10.49's Unicode tables (Unicode 17; checked on PostgreSQL 17 and MySQL 8.4). A later Unicode version can add format characters that only the PHP scan sees. On PostgreSQL, with standard_conforming_strings on (the default):
SELECT id, email FROM users WHERE email ~ '[\u0001-\u001F\u007F-\u00A0\u00AD\u034F\u0488-\u0489\u0600-\u0605\u061C\u06DD\u070F\u0890-\u0891\u08E2\u1680\u180E\u1ABE\u2000-\u200B\u200E-\u200F\u2028-\u202F\u205F-\u2064\u2066-\u206F\u20DD-\u20E0\u20E2\u20E4\u3000\uA670-\uA672\uFEFF\uFFF9-\uFFFB\U000110BD\U000110CD\U00013430-\U0001343F\U0001BCA0-\U0001BCA3\U0001D173-\U0001D17A\U000E0001\U000E0020-\U000E007F]';On MySQL 8.0.4+ (ICU regular expressions). With NO_BACKSLASH_ESCAPES in sql_mode, the doubled backslashes reach the regex as written and it fails with error 3697 — run it in a session without that mode, or halve them:
SELECT id, email FROM users WHERE email REGEXP '[\\x{0000}-\\x{001F}\\x{007F}-\\x{00A0}\\x{00AD}\\x{034F}\\x{0488}-\\x{0489}\\x{0600}-\\x{0605}\\x{061C}\\x{06DD}\\x{070F}\\x{0890}-\\x{0891}\\x{08E2}\\x{1680}\\x{180E}\\x{1ABE}\\x{2000}-\\x{200B}\\x{200E}-\\x{200F}\\x{2028}-\\x{202F}\\x{205F}-\\x{2064}\\x{2066}-\\x{206F}\\x{20DD}-\\x{20E0}\\x{20E2}\\x{20E4}\\x{3000}\\x{A670}-\\x{A672}\\x{FEFF}\\x{FFF9}-\\x{FFFB}\\x{110BD}\\x{110CD}\\x{13430}-\\x{1343F}\\x{1BCA0}-\\x{1BCA3}\\x{1D173}-\\x{1D17A}\\x{E0001}\\x{E0020}-\\x{E007F}]';ZWNJ, ZWJ and the keycap are accepted, so neither query matches them. On PostgreSQL two accounts may differ only by one of them (ab and a\u{200D}b): the throttle folds them onto one bucket, as it does accounts differing only in case or accents. MySQL's collation already treats them as one value, so unique refuses the second.
WARNING
lukk 0.6.0 adds a guard column to passkeys and turns account deletion on by default.
The column is folded into the existing passkeys migration, so a fresh install needs no action and a single-guard install is unaffected (the column stays null and no query names it). An install that already ran that migration and uses multiple guards must add the column and backfill it — until it does, lukk:prune deliberately sweeps no passkeys at all rather than risk deleting a credential it cannot attribute.
Removing a guard needs the opposite cleanup, and it fails open: rows still stamped with the departed guard's name become visible to the default guard, and for passkeys that lookup is the authentication decision. Delete or re-home them first.
features.account_deletion defaults to true, so upgrading adds an irreversible route (DELETE /auth/account). Turn it off if deletion is owned elsewhere. Both directions and the SQL are in lukk UPGRADE.md.
NOTE
lukk 0.4.0 added a guard column to refresh_tokens on the same terms — folded into the core migration, no action for fresh or single-guard installs, and the identical backfill caveat in both directions. See lukk UPGRADE.md.
On the client side, lukk-nuxt 0.12.0 (with lukk-core 0.12.0) is the first release with anything to do on upgrade. No runtime API was renamed or removed, but behaviour changed in ways an app can notice even without typechecking, and every composable now declares a real return type where the published declarations said any, so a typecheck that passed before can fail — most often on user.value.name, which needs a LukkUser augmentation. LukkAuth also becomes a global auto-imported type, and lukk-auth now defers rather than redirecting during a server render that couldn't identify the visitor. Each with its migration step in lukk-js UPGRADE.md.
Changes that need a look:
- Passkey sign-in can return a two-factor challenge, and
Amrchanged (lukk 0.7.0).useLukkPasskeys().login()resolves to aLoginResult: when lukk answers a single-factor passkey with a challenge,pendingTwoFactorturns true and no user is loaded — show the same code form as after a password sign-in.confirm()uses lukk 0.7.0's step-up options, falling back to the login options on a404from an older lukk. TheAmrtype is'pwd' | 'otp' | 'pop' | 'user' | 'mfa';'webauthn'is gone. login(),register()and passkeylogin()are typed for BFF mode. There they resolve to the proxy's{ ok: true, expires_in }— the newBffSignInResult— so they now returnLoginResult | BffSignInResult(RegisterResult | BffSignInResult). Readingaccess_tokenneeds a narrowing first ('access_token' in result, orisTwoFactorChallenge). See lukk-core.- The export's passkey times are strings (lukk 0.7.0).
AccountExport.passkeys[]gains the optionalcreated_at,aaguidandtransports, and itslast_used_atis an ISO-8601 string — it was unix seconds, sonew Date(entry.last_used_at * 1000)now fails to typecheck and givesNaNat runtime.PasskeySummaryfromGET /auth/passkeysis unchanged:last_used_atis still a number (the declaration used to saystring).AccountExportalso gains an optionallockoutsarray. - New refusals from lukk 0.7.0, passed through as plain
LukkErrors — no refresh, no retry, nothing signed out:useLukkTwoFactor().confirm()rejects409(oncode) when two-factor is already on,regenerateRecoveryCodes()409(ontwo_factor) without two-factor, anduseLukkChangePassword().changePassword()can reject422oncurrent_passwordfrom a session below the account's step-up level (the remedy is signing in again with the second factor), anduseLukkConfirmation().confirmPassword()422onpasswordon the same terms — there the step-up is a user-verifying passkey (useLukkPasskeys().confirm()) or a fresh sign-in with the second factor, so a modal that only asks for the password should offer one. A two-factor challenge issued before the password changed is refused like an expired one; the user signs in again. storageis reserved. It was read by nothing — BFF tokens always lived in the sealed session cookie. Any value but'cookie'now fails the build; remove the option. For ended sessions shared across instances, usesession.sharedStore.- Direct mode keeps its credentials out of
useState. The access token, the step-up token and a pending 2FA challenge are held on the Nuxt app, not in thelukk:access,lukk:confirmationandlukk:challengestate keys, which are gone:useStateis the payload, and Nuxt's chunk-reload persists it tosessionStorage. Use the composables;clearNuxtState()no longer clears them —logout()does. - The account calls resolve to
{ status }.forgotPassword,resetPassword,changePasswordandsendEmailVerificationresolve toLukkStatus({ status: string }) instead ofvoid— what lukk always sent. refreshhooks are typed() => Promise<RefreshOutcome>and may returnREFRESHED_WITHOUT_TOKEN. In BFF mode$lukkRefresh()resolves to that symbol rather than the proxy's{ ok, expires_in }(code reading.expires_ingetsundefined), and so does thepairof$lukkRestore's result. Both are truthy on success.$lukkRestoreis typed in templates.- Sign-in calls no longer refresh and retry on a
401.login,register,twoFactorChallengeandloginWithPasskeyreject with it — in lukk-core and in the BFF proxy.logout()keeps its refresh-and-retry and accepts{ retry: false };logout({ refreshToken })presents a body-mode refresh token, so lukk 0.7.0 ends the session even with an expired access token. lukk-core:LukkError.retryAftercarries a numericRetry-After, and the newretryAfterSeconds()export reads one; app-API errors fromuseLukkFetchcarry it too.
The proxies refuse more:
- The app-API proxy answers
404for lukk's own routes. If your client reached lukk through/api/..., use the composables. If lukk sits at the root ofapi.target's host, the proxy refuses everything — move lukk under a prefix. - …including behind a PHP front controller. Every
.phpin the path is read as the end of a script's name, as Symfony strips it — with no/needed after it, so/api/index.phpauth/loginis refused as well as/api/index.php/auth/loginand/app/index.php/auth/loginunder a sub-path — in the path and inbaseURL(https://host/index.php/auth), on every decoding round, with the tab, LF and CR a re-parsing hop strips removed first; the path is judged as written too. A script in a subdirectory is read the way Symfony strips it, with everything before it (/api/public/index.php/auth/login,/api/x/public/index.php/auth/login). Where a path names a script without starting with its path, Symfony cuts as many characters as the deploy'sSCRIPT_NAMEhas — a length the proxy can't know — so a path that names a.phpanywhere and holds lukk's last base segment (auth, ended by/,\,?,#or the end) anywhere is refused. That refuses some paths that never reach lukk, such as/api/oauth/callback.php: rename such a route, or serve it without.php. So is a path naming more than four.php(in any decoding round), whatever it holds: each is a reading judged in full, so their number is bounded rather than the cost being quadratic in the path. Paths without.phpare judged as before. It still can't see the front controller where no.phpnames it — shared hosting whose.htaccessrewrites intopublic/index.phproutes/public/auth/loginas/auth/login— nor lukk's extra guard mounts on the same host: pointapi.targetat a host or prefix that does not serve lukk's routes at all. Details. - The app-API proxy refuses a path that further percent-decoding turns into lukk's routes (
/api/x/%252e%252e/auth/login,/api/%2561uth/login, and one behind a decoded?or#) with404— and any path in which decoding reveals a whole.or..segment, wherever it leads:/api/files/a%2F..%2Fb,/api/files/foo%2F.%2Fbar.txt,/api/..%2Fx,/api/a%5c..%5cb, and one a tab, LF or CR splits (.%2509.). A dot inside a segment name (v1.2,.well-known,..y) is unaffected. Check that no real API path carries an encoded separator around a dot segment. - Route checks collapse repeated slashes, so
/api//auth/loginis refused like/api/auth/login. - The auth proxy refuses a path lukk routes nowhere but a hop in front might with
404: an empty segment (/api/_lukk//logout), an encoded slash or backslash (%2F,%5C) or an encoded%(%25). A trailing run of slashes is still forwarded. Details. - Both proxies answer
414for a path longer than 8 KiB as the upstream URL serialises it (query aside; a{or"counts three, as%7B/%22) — nginx's and Apache's default request-line limit. - Both proxies refuse a
cross-siteorsame-siteGET that isn't a top-level navigation (an<img>pointing at them, say) with403— the app-API proxy now too, since it injects the bearer. OnlySec-Fetch-Dest: documentcounts as top-level, so a navigation into an<iframe>,<frame>,<embed>or<object>from another site is refused. Links the visitor follows still work. /api/_lukk/refreshacceptsPOSTonly; anything else is405withAllow: POST.- The auth proxy caps request bodies at
bodyLimit(1 MiB): over it is413, a chunked body without a length411. Every auth-proxy response carriesX-Content-Type-Options: nosniff, and a non-JSON upstream body goes out astext/plain. - App-API responses carry
X-Content-Type-Options: nosniff, and any that is neither JSON nor a PDF alsoContent-Security-Policy: sandbox, added to the upstream's own policy — an HTML or SVG document from your API renders without script and outside the app's origin. Serve such documents from another origin if they need to run script. - The app-API proxy keeps CORS your app's own: it drops the upstream's
Access-Control-*and hop-by-hop response headers and removesOriginupstream. The headers it strips (Origin,Cookie, the spoofable forwarding set, anything named inConnection) are now removed, not forwarded empty. A header the browser itself sent empty is still forwarded as sent —Acceptincluded, withapi.forceJsonoff.
Sessions and rotation:
- Sealed sessions expire after
session.maxAge(30 days); keep it at least lukk'srefresh_ttl. A seal written before 0.12.0 has no lifetime until it is next re-sealed.session.maxAgemust be a positive whole number of seconds: anything else fails the build (0used to seal sessions that never expire), and a runtime override that isn't one makes every session write throw. - The BFF session cookie now persists for
session.maxAge, restarting on every write. It used to carry noMax-Age, so BFF users were signed out whenever the browser closed; now they stay signed in like direct-mode users. A cleared session's cookie is deleted. - A refresh is never abandoned on a timer. The 15-second upstream deadline covers the auth proxy's own calls to lukk, body included, and the background revoke of a dropped session — but never a refresh: lukk commits a rotation on receipt, so abandoning it would lose the new token and the next refresh, past grace, would revoke the whole family (RFC 9700 §4.14.2). No request waits on a slow refresh for more than 15 s either:
/api/_lukk/refreshthen answers503withRetry-After: 5, keeping the session (lukk-nuxt's client retries once after it, standing down for a logout); a proxied or app-API call goes ahead with the old access token; an SSR render leaves the session to the client. A refresh that never settles stops being shared after five minutes. - Every rotation is journalled, and its straggler window starts when its cookie leaves. A link from the consumed token to the pair it produced is recorded untaken and held ten minutes; a request still presenting a consumed token is handed the chain's newest pair instead of replaying it, and that takes nothing. The 30 s for stragglers start at delivery — when a caller's cookie carrying the pair actually leaves: the auth proxy's
/refreshand 401 retry, a step-up confirmation, the app-API proxy when the upstream answers (or on its502), an SSR render when the page is done — set outright on the first delivery, after that only ever brought forward, never past the next link's end. A response whose browser went away before it could be sent (aborted request, destroyed response or socket) delivers nothing at any delivery point, so its link stays held. At delivery a caller seals the newest pair if the session rotated the one it was handed — or, if the session has moved past it and the links that led on are gone, withholds its cookie so the browser keeps the newer one:/api/_lukk/refreshand a step-up then answer409(the step-up is not recorded, and the user confirms again). The BFF remembers each session's newest refresh token for that check ten minutes past the latest rotation, hand-out or response, and for as long as any response that may seal a pair for the session is still out — an app-API long-poll has no deadline.expires_incounts from when lukk minted the token. A logout, and the revoke of a session a sign-in replaced, present the journal's current pair rather than the cookie's spent one, so lukk ends the session through the bearer instead of reading a replay (and firingRefreshTokenReused) — including a session sealed by an earlier lukk-nuxt release, whose rotations are journalled under a different key. Two deliberate deviations, bounded and traded against a false logout: the journal can answer a replay up to 10 min 30 s after lukk rotated (a rotation whose cookie never left), or a slow response's duration + 30 s — past lukk's own grace window — and those replays never reach lukk, so its reuse detection andRefreshFamilyForkeddon't see them (RFC 9700 §4.14.2). The journal is per process: behind a multi-instance BFF without sticky sessions, use sticky sessions or raise lukk's grace window. - A page render's own requests never rotate the session. A BFF server plugin marks the page request (
x-lukk-ssr, removed before a request reaches your API), souseLukkFetchand Nuxt's ownuseFetch('/api/…'),useRequestFetch()andevent.$fetchinside the render pass the token on as it is, through the app-API proxy and the auth proxy alike (a marked/refreshanswers401; a logout still renews, to revoke). The session is renewed once, before the render — by SSR hydration, or withssrHydrate: falseby a renew-only step that loads no user — so withssrHydrate: falsean aged-out page load rotates twice (the server's renewal, then the client's restore), and both count against lukk's per-IP refresh throttle. A server route'sevent.$fetch('/api/…')is not marked: read the session withgetLukkAccessTokenthere. Details. - A
withConfirmation()still waiting for a step-up is cancelled — it rejects — when the session ends by logout or a sign-in replaces it. It retries once, instead of asking again, when a confirmation landed while its request was out. A step-up that can never be earned (403) rejects the waiting action even when the modal is a differentuseLukkConfirmation()instance. A 2FA challenge left from an earlier attempt is dropped by any later sign-in, and when another tab changes the session. - A request made for one account is not replayed as another. In direct mode, when a
401renewal comes back as a different account (another tab signed in; the refresh cookie is shared), lukk-core's client anduseLukkFetchno longer retry the request: it rejects with a401whosereasonisSESSION_CHANGED. The new session is kept and the user reloaded; repeating the action as that account is the caller's call. A401from another origin — where no credentials went — is no longer renewed for at all.useLukkAuth's user load doesn't read that rejection as a sign-out: it asks again, once, as the account the session now holds, and failing that counts as a load that couldn't tell, leaving the next refresh to notice the switch. - A step-up belongs to the session that earned it. A sign-in (here or in another tab), or a renewal that turns out to be another account, drops
confirmedand the held confirmation token, so thelukk-confirmedmiddleware no longer lets the new account through on the old one's step-up. - An expired or invalid two-factor challenge (
422onchallenge_token) clearspendingTwoFactor, so the user signs in again instead of spending the throttle on it. A wrong code keeps it. experimental.restoreState: lukk's session state (lukk:user,lukk:ready,lukk:confirmed, …) is taken out of the snapshot Nuxt saves when it reloads the page itself (a chunk error, a new deploy), so it can no longer be restored over the session the new page resolved.useLukkFormdrafts are kept.
useLukkFetch, useLukkForm and the base path:
useLukkFetchsends credentials only where it has checked they belong. Its default iscredentials: 'same-origin', upgraded per request; a call that passes its ownonRequest(replacing lukk's) or a per-callbaseURLon another origin now goes out without the bearer (direct mode) or the cookies (any cross-origin target) and gets a401. In BFF mode a same-origin call still carries the session cookie, so it is unaffected. Wrap the instance rather than replacing its hook. A relative path with an explicitly emptybaseURLgoes to the page's origin and, in direct mode with an absolute API base, carries no credentials unless the page is on the API's origin. Details.- In direct mode
useLukkFetchattaches the held step-up token to requests on the API's own origin, as it does the bearer, sowithConfirmation()can reach alukk.confirm-gated route of your API. - Direct-mode SSR no longer sends the visitor's cookies to an absolute API base, no longer trusts
Hoston the server, and keeps anAcceptyou set. useLukkForm: drafts move tolukk:form:<rememberKey>(a draft under the old key isn't carried over); a cancelled submit rejects with theAbortErroritself rather than ofetch'sFetchError;onFinishruns even whenonErrorthrows. A new submit aborts one still in flight, and a superseded one that settles anyway no longer touchesprocessing,errorsor the success state.errorsaccepts Laravel's dotted keys (errors['authorization.role']), andnestedErrorsis typed alongdata. Details.user.endpointresolves underapp.baseURLin BFF mode, like the proxy mount it points at. In direct modeuseLukkFetchno longer prefixes a relativeapi.targetwith the app's base. Action needed if you worked around the old behaviour with a base-prefixed relative endpoint:'/admin/api/me'underapp.baseURL: '/admin/'now resolves to/admin/admin/api/meand404s, which signs everyone out on load. Drop the prefix ('/api/me'). An absolute endpoint keeps working.- Types:
isTwoFactorChallengeandisRegistrationPendingaccept any value, soisTwoFactorChallenge(await login(...))type-checks on the composables' results. The module's nested options (api,session,user) are optional field by field, as their defaults fill them — a documentedapi: { path, target }no longer fails the app's typecheck. In BFF modeuseNuxtApp().$lukkis typedLukkBffClient: its sign-ins and renewals resolve the proxy's{ ok, expires_in }, and a step-up resolves without its token —useLukkConfirmation().record()accepts that{ ok: true }as well as{ confirmation_token }. The module's own composables and proxy now type-check under BFF mode too. Details. confirmationHeaderis validated as it ends up — one set throughruntimeConfig.public.lukkfails the build too. Details.- Packaging:
LUKK_BFF_PREFIXandLUKK_SESSION_COOKIEimport as values again (import { LUKK_BFF_PREFIX } from 'lukk-nuxt'failed with TS1362),h3is a declared dependency, and the internalrefreshOncehelper is no longer auto-imported into your server code.