diff --git a/app/Http/Middleware/ConfigurePasskeys.php b/app/Http/Middleware/ConfigurePasskeys.php
new file mode 100644
index 0000000000..e5465c28a4
--- /dev/null
+++ b/app/Http/Middleware/ConfigurePasskeys.php
@@ -0,0 +1,28 @@
+routeIs('passkey.*')) {
+ return $next($request);
+ }
+
+ $relyingParty = instanceSettings()->is_passkey_enabled ? PasskeyRelyingParty::resolve($request) : null;
+ abort_if($relyingParty === null, 404);
+
+ config([
+ 'passkeys.relying_party_id' => $relyingParty['id'],
+ 'passkeys.allowed_origins' => $relyingParty['origins'],
+ ]);
+
+ return $next($request);
+ }
+}
diff --git a/app/Livewire/Profile/Passkeys.php b/app/Livewire/Profile/Passkeys.php
new file mode 100644
index 0000000000..2620e92d90
--- /dev/null
+++ b/app/Livewire/Profile/Passkeys.php
@@ -0,0 +1,70 @@
+show_form = session('status') === 'passkey-add';
+ }
+
+ public function registered(): void
+ {
+ $this->show_form = false;
+ $this->dispatch('success', 'Passkey added.');
+ }
+
+ public function rename(int $id, string $name): bool
+ {
+ abort_unless(instanceSettings()->is_passkey_enabled, 404);
+
+ $passkey = Auth::user()->passkeys()->findOrFail($id);
+
+ $validated = Validator::make(
+ ['name' => trim($name)],
+ ['name' => ['required', 'string', 'max:255']],
+ )->validate();
+
+ $passkey->update(['name' => $validated['name']]);
+ $this->dispatch('success', 'Passkey renamed.');
+
+ return true;
+ }
+
+ public function delete(int $id, mixed $password = null): mixed
+ {
+ abort_unless(instanceSettings()->is_passkey_enabled, 404);
+
+ $passkey = Auth::user()->passkeys()->findOrFail($id);
+
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return 'The provided password is incorrect.';
+ }
+
+ app(DeletePasskey::class)(Auth::user(), $passkey);
+ $this->dispatch('success', 'Passkey deleted.');
+
+ return true;
+ }
+
+ public function render(): View
+ {
+ $enabled = (bool) instanceSettings()->is_passkey_enabled;
+
+ return view('livewire.profile.passkeys', [
+ 'enabled' => $enabled,
+ 'available' => $enabled && PasskeyRelyingParty::isAvailable(),
+ 'passkeys' => $enabled ? Auth::user()->passkeys()->latest()->get() : collect(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index a3855eac3b..28cbc971d2 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -41,6 +41,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
+ #[Validate('boolean')]
+ public bool $is_passkey_enabled;
+
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
@@ -72,6 +75,7 @@ class Advanced extends Component
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
+ 'is_passkey_enabled' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'nullable|string',
@@ -95,6 +99,7 @@ class Advanced extends Component
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
+ $this->is_passkey_enabled = $this->settings->is_passkey_enabled ?? true;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
@@ -216,6 +221,7 @@ class Advanced extends Component
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
+ $this->settings->is_passkey_enabled = $this->is_passkey_enabled;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php
index 16a1d7ea30..480beb9088 100644
--- a/app/Models/InstanceSettings.php
+++ b/app/Models/InstanceSettings.php
@@ -59,6 +59,7 @@ class InstanceSettings extends Model
'avatar_s3_storage_id',
'image_cdn_url',
'is_dashboard_force_https_enabled',
+ 'is_passkey_enabled',
];
protected $hidden = [
@@ -100,6 +101,7 @@ class InstanceSettings extends Model
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
'is_dashboard_force_https_enabled' => 'boolean',
+ 'is_passkey_enabled' => 'boolean',
];
protected static function booted(): void
diff --git a/app/Models/User.php b/app/Models/User.php
index c5e9f592c3..743871fcfc 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -21,6 +21,8 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
+use Laravel\Fortify\Contracts\PasskeyUser;
+use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Sanctum\NewAccessToken;
@@ -41,9 +43,9 @@ use OpenApi\Attributes as OA;
'marketing_emails' => ['type' => 'boolean', 'description' => 'The flag to receive marketing emails.'],
],
)]
-class User extends Authenticatable implements SendsEmail
+class User extends Authenticatable implements PasskeyUser, SendsEmail
{
- use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
+ use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
protected $fillable = [
'name',
@@ -599,6 +601,18 @@ class User extends Authenticatable implements SendsEmail
return $this->oauthIdentities()->exists();
}
+ /**
+ * Hex handle: iOS Safari corrupts raw binary handles (laravel/passkeys-server#41).
+ */
+ public function getPasskeyUserHandle(): string
+ {
+ return hash_hmac(
+ 'sha256',
+ $this->getTable().'|'.$this->getKey(),
+ Config::string('passkeys.user_handle_secret'),
+ );
+ }
+
/**
* Check if the user has a password set.
*/
diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php
index e9da72d442..a894b7f3d9 100644
--- a/app/Providers/EventServiceProvider.php
+++ b/app/Providers/EventServiceProvider.php
@@ -14,6 +14,9 @@ use Laravel\Fortify\Events\RecoveryCodesGenerated;
use Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed;
use Laravel\Fortify\Events\TwoFactorAuthenticationDisabled;
use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled;
+use Laravel\Passkeys\Events\PasskeyDeleted;
+use Laravel\Passkeys\Events\PasskeyRegistered;
+use Laravel\Passkeys\Events\PasskeyVerified;
use SocialiteProviders\Authentik\AuthentikExtendSocialite;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Clerk\ClerkExtendSocialite;
@@ -72,6 +75,15 @@ class EventServiceProvider extends ServiceProvider
Event::listen(RecoveryCodesGenerated::class, function (RecoveryCodesGenerated $event): void {
auditLog('auth.user.recovery_codes_regenerated', $this->authContext($event->user));
});
+ Event::listen(PasskeyRegistered::class, function (PasskeyRegistered $event): void {
+ auditLog('auth.user.passkey_registered', $this->authContext($event->user));
+ });
+ Event::listen(PasskeyVerified::class, function (PasskeyVerified $event): void {
+ auditLog('auth.user.passkey_verified', $this->authContext($event->user));
+ });
+ Event::listen(PasskeyDeleted::class, function (PasskeyDeleted $event): void {
+ auditLog('auth.user.passkey_deleted', $this->authContext($event->user));
+ });
}
/**
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
index 9bdc5d7371..d6519aac0e 100644
--- a/app/Providers/FortifyServiceProvider.php
+++ b/app/Providers/FortifyServiceProvider.php
@@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Models\OauthSetting;
use App\Models\User;
+use App\Support\PasskeyRelyingParty;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\ServiceProvider;
@@ -68,6 +69,7 @@ class FortifyServiceProvider extends ServiceProvider
fn (OauthSetting $oauthSetting) => $oauthSetting->couldBeEnabled() && $oauthSetting->allowsUserCreation()
),
'enabled_oauth_providers' => $enabled_oauth_providers,
+ 'passkeys_available' => PasskeyRelyingParty::isAvailable(),
]);
});
@@ -108,7 +110,10 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::confirmPasswordView(function () {
- return view('auth.confirm-password');
+ return view('auth.confirm-password', [
+ 'can_confirm_with_passkey' => PasskeyRelyingParty::isAvailable()
+ && auth()->user()->hasPasskeysEnabled(),
+ ]);
});
Fortify::twoFactorChallengeView(function () {
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index 24cec00b4c..dedac3a095 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -74,6 +74,10 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
+ RateLimiter::for('passkeys', function (Request $request) {
+ return Limit::perMinute(30)->by('passkeys:'.auth_rate_limit_ip($request));
+ });
+
RateLimiter::for('forgot-password', function (Request $request) {
$limits = [
Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))),
diff --git a/app/Support/PasskeyRelyingParty.php b/app/Support/PasskeyRelyingParty.php
new file mode 100644
index 0000000000..c066feec41
--- /dev/null
+++ b/app/Support/PasskeyRelyingParty.php
@@ -0,0 +1,49 @@
+}|null
+ */
+ public static function resolve(Request $request): ?array
+ {
+ $requestHost = $request->getHost();
+
+ foreach ([instanceSettings()->fqdn, config('app.url')] as $url) {
+ $parts = parse_url((string) $url);
+ $host = strtolower($parts['host'] ?? '');
+
+ if (strtolower($parts['scheme'] ?? '') !== 'https' || $host !== $requestHost) {
+ continue;
+ }
+ if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false) {
+ continue;
+ }
+
+ $origins = ['https://'.$host.(isset($parts['port']) ? ':'.$parts['port'] : '')];
+
+ // The fqdn setting stores no port, so a custom port can only come from the request.
+ if (! isset($parts['port']) && ! in_array($request->getPort(), [80, 443])) {
+ $origins[] = 'https://'.$host.':'.$request->getPort();
+ }
+
+ return ['id' => $host, 'origins' => $origins];
+ }
+
+ if ($requestHost === 'localhost') {
+ return ['id' => 'localhost', 'origins' => [$request->getSchemeAndHttpHost()]];
+ }
+
+ return null;
+ }
+
+ public static function isAvailable(?Request $request = null): bool
+ {
+ return (bool) instanceSettings()->is_passkey_enabled
+ && self::resolve($request ?? request()) !== null;
+ }
+}
diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php
index 971afdfb79..afe1631a19 100644
--- a/bootstrap/helpers/subscriptions.php
+++ b/bootstrap/helpers/subscriptions.php
@@ -85,6 +85,11 @@ function allowedPathsForUnsubscribedAccounts()
// Account basics stay available without a paid plan.
'profile',
'profile/appearance',
+ 'profile/passkeys/confirm',
+ 'user/passkeys',
+ 'user/passkeys/options',
+ 'passkeys/confirm',
+ 'passkeys/confirm/options',
];
}
function allowedPathsForBoardingAccounts()
diff --git a/config/fortify.php b/config/fortify.php
index 5b7864af01..03b1c70ae0 100644
--- a/config/fortify.php
+++ b/config/fortify.php
@@ -1,5 +1,6 @@
['web'],
+ 'middleware' => ['web', ConfigurePasskeys::class],
/*
|--------------------------------------------------------------------------
@@ -106,6 +107,7 @@ return [
'login' => 'login',
'two-factor' => 'two-factor',
'forgot-password' => 'forgot-password',
+ 'passkeys' => 'passkeys',
],
/*
@@ -121,6 +123,22 @@ return [
'views' => true,
+ /*
+ |--------------------------------------------------------------------------
+ | Passkeys
+ |--------------------------------------------------------------------------
+ |
+ | Defaults only. ConfigurePasskeys sets the relying party for each request.
+ |
+ */
+
+ 'passkeys' => [
+ 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
+ 'allowed_origins' => [config('app.url')],
+ 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
+ 'timeout' => 60000,
+ ],
+
/*
|--------------------------------------------------------------------------
| Features
@@ -142,6 +160,9 @@ return [
'confirmPassword' => true,
// 'window' => 0,
]),
+ Features::passkeys([
+ 'confirmPassword' => true,
+ ]),
],
];
diff --git a/database/migrations/2026_10_12_164601_create_passkeys_table.php b/database/migrations/2026_10_12_164601_create_passkeys_table.php
new file mode 100644
index 0000000000..cb884c3988
--- /dev/null
+++ b/database/migrations/2026_10_12_164601_create_passkeys_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('name');
+ $table->string('credential_id')->unique();
+ $table->json('credential');
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+
+ $table->index('user_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('passkeys');
+ }
+};
diff --git a/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php
new file mode 100644
index 0000000000..674486416d
--- /dev/null
+++ b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php
@@ -0,0 +1,28 @@
+boolean('is_passkey_enabled')->default(true);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn('is_passkey_enabled');
+ });
+ }
+};
diff --git a/package-lock.json b/package-lock.json
index 3f21ba9329..f70d8ae9ea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"name": "coolify",
"dependencies": {
+ "@laravel/passkeys": "^0.4.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/typography": "0.5.20",
"@tanstack/virtual-core": "3.17.11",
@@ -87,6 +88,34 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@laravel/passkeys": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@laravel/passkeys/-/passkeys-0.4.0.tgz",
+ "integrity": "sha512-59UHJHZPrIT8f5hFQJUR1EXhazWZTg3DcJefxSfA3aRj6xu40I/lYDveUY4oL2/h/crq9jjptSKykZDDold7aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@simplewebauthn/browser": "^13.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "svelte": "^5.0.0",
+ "vue": "^3.3.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@oxc-project/types": {
"version": "0.143.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
@@ -360,6 +389,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "13.3.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
+ "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/forms": {
"version": "0.5.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz",
@@ -1248,7 +1283,7 @@
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
diff --git a/package.json b/package.json
index 982aee11d7..741c243bf4 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"vite": "8.2.1"
},
"dependencies": {
+ "@laravel/passkeys": "^0.4.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/typography": "0.5.20",
"@tanstack/virtual-core": "3.17.11",
diff --git a/resources/js/passkeys.js b/resources/js/passkeys.js
new file mode 100644
index 0000000000..060e1ad00d
--- /dev/null
+++ b/resources/js/passkeys.js
@@ -0,0 +1,22 @@
+import { Passkeys } from '@laravel/passkeys';
+
+window.Passkeys = Passkeys;
+
+window.passkeyDeviceName = () => {
+ const ua = navigator.userAgent;
+ const browser = /Edg\//.test(ua) ? 'Edge'
+ : /Firefox\//.test(ua) ? 'Firefox'
+ : /Chrome\//.test(ua) ? 'Chrome'
+ : /Safari\//.test(ua) ? 'Safari'
+ : 'Browser';
+ const os = /iPhone|iPad/.test(ua) ? 'iOS'
+ : /Android/.test(ua) ? 'Android'
+ : /Mac/.test(ua) ? 'Mac'
+ : /Windows/.test(ua) ? 'Windows'
+ : /Linux/.test(ua) ? 'Linux'
+ : 'device';
+
+ return `${browser} on ${os}`;
+};
+
+window.dispatchEvent(new CustomEvent('passkeys:ready'));
diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php
index 9d2bc7be54..ee5cbb0858 100644
--- a/resources/views/auth/confirm-password.blade.php
+++ b/resources/views/auth/confirm-password.blade.php
@@ -28,6 +28,11 @@
{{ __('auth.confirm_password') }}
+
+ @if ($can_confirm_with_passkey)
+
Sign in with your fingerprint, face, or a security key. A passkey sign-in skips two-factor authentication.
+localhost. An IP address does not work.
+ + {{ $passkey->authenticator ?? 'Passkey' }} + · Added {{ $passkey->created_at->diffForHumans() }} + · {{ $passkey->last_used_at ? 'Last used '.$passkey->last_used_at->diffForHumans() : 'Not used yet' }} +
+{{ $message }}
+ @enderror +