diff --git a/models/auth/webauthn.go b/models/auth/webauthn.go
index a65d2e1e343d..553130ee2e9e 100644
--- a/models/auth/webauthn.go
+++ b/models/auth/webauthn.go
@@ -181,7 +181,7 @@ func DeleteCredential(ctx context.Context, id, userID int64) (bool, error) {
 	return had > 0, err
 }
 
-// WebAuthnCredentials implementns the webauthn.User interface
+// WebAuthnCredentials implements the webauthn.User interface
 func WebAuthnCredentials(ctx context.Context, userID int64) ([]webauthn.Credential, error) {
 	dbCreds, err := GetWebAuthnCredentialsByUID(ctx, userID)
 	if err != nil {
diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go
index 189d197333e0..790006ee567c 100644
--- a/modules/auth/webauthn/webauthn.go
+++ b/modules/auth/webauthn/webauthn.go
@@ -31,7 +31,7 @@ func Init() {
 			RPID:          setting.Domain,
 			RPOrigins:     []string{appURL},
 			AuthenticatorSelection: protocol.AuthenticatorSelection{
-				UserVerification: "discouraged",
+				UserVerification: protocol.VerificationDiscouraged,
 			},
 			AttestationPreference: protocol.PreferDirectAttestation,
 		},
@@ -66,7 +66,7 @@ func (u *User) WebAuthnIcon() string {
 	return (*user_model.User)(u).AvatarLink(db.DefaultContext)
 }
 
-// WebAuthnCredentials implementns the webauthn.User interface
+// WebAuthnCredentials implements the webauthn.User interface
 func (u *User) WebAuthnCredentials() []webauthn.Credential {
 	dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID)
 	if err != nil {
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 815cba6eecaf..d10f61f2ffc9 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -458,6 +458,7 @@ sspi_auth_failed = SSPI authentication failed
 password_pwned = The password you chose is on a <a target="_blank" rel="noopener noreferrer" href="https://haveibeenpwned.com/Passwords">list of stolen passwords</a> previously exposed in public data breaches. Please try again with a different password and consider changing this password elsewhere too.
 password_pwned_err = Could not complete request to HaveIBeenPwned
 last_admin = You cannot remove the last admin. There must be at least one admin.
+signin_passkey = Sign in with a passkey
 
 [mail]
 view_it_on = View it on %s
diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go
index 1079f44a085b..3160c5e23f03 100644
--- a/routers/web/auth/webauthn.go
+++ b/routers/web/auth/webauthn.go
@@ -4,6 +4,7 @@
 package auth
 
 import (
+	"encoding/binary"
 	"errors"
 	"net/http"
 
@@ -47,6 +48,104 @@ func WebAuthn(ctx *context.Context) {
 	ctx.HTML(http.StatusOK, tplWebAuthn)
 }
 
+// WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser
+func WebAuthnPasskeyAssertion(ctx *context.Context) {
+	assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
+	if err != nil {
+		ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
+		return
+	}
+
+	if err := ctx.Session.Set("webauthnPasskeyAssertion", sessionData); err != nil {
+		ctx.ServerError("Session.Set", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, assertion)
+}
+
+// WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey
+func WebAuthnPasskeyLogin(ctx *context.Context) {
+	sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData)
+	if !okData || sessionData == nil {
+		ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session"))
+		return
+	}
+	defer func() {
+		_ = ctx.Session.Delete("webauthnPasskeyAssertion")
+	}()
+
+	// Validate the parsed response.
+	var user *user_model.User
+	cred, err := wa.WebAuthn.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
+		userID, n := binary.Varint(userHandle)
+		if n <= 0 {
+			return nil, errors.New("invalid rawID")
+		}
+
+		var err error
+		user, err = user_model.GetUserByID(ctx, userID)
+		if err != nil {
+			return nil, err
+		}
+
+		return (*wa.User)(user), nil
+	}, *sessionData, ctx.Req)
+	if err != nil {
+		// Failed authentication attempt.
+		log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err)
+		ctx.Status(http.StatusForbidden)
+		return
+	}
+
+	if !cred.Flags.UserPresent {
+		ctx.Status(http.StatusBadRequest)
+		return
+	}
+
+	if user == nil {
+		ctx.Status(http.StatusBadRequest)
+		return
+	}
+
+	// Ensure that the credential wasn't cloned by checking if CloneWarning is set.
+	// (This is set if the sign counter is less than the one we have stored.)
+	if cred.Authenticator.CloneWarning {
+		log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr())
+		ctx.Status(http.StatusForbidden)
+		return
+	}
+
+	// Success! Get the credential and update the sign count with the new value we received.
+	dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
+	if err != nil {
+		ctx.ServerError("GetWebAuthnCredentialByCredID", err)
+		return
+	}
+
+	dbCred.SignCount = cred.Authenticator.SignCount
+	if err := dbCred.UpdateSignCount(ctx); err != nil {
+		ctx.ServerError("UpdateSignCount", err)
+		return
+	}
+
+	// Now handle account linking if that's requested
+	if ctx.Session.Get("linkAccount") != nil {
+		if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil {
+			ctx.ServerError("LinkAccountFromStore", err)
+			return
+		}
+	}
+
+	remember := false // TODO: implement remember me
+	redirect := handleSignInFull(ctx, user, remember, false)
+	if redirect == "" {
+		redirect = setting.AppSubURL + "/"
+	}
+
+	ctx.JSONRedirect(redirect)
+}
+
 // WebAuthnLoginAssertion submits a WebAuthn challenge to the browser
 func WebAuthnLoginAssertion(ctx *context.Context) {
 	// Ensure user is in a WebAuthn session.
diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go
index e382c8b9af41..1b8d0171f563 100644
--- a/routers/web/user/setting/security/webauthn.go
+++ b/routers/web/user/setting/security/webauthn.go
@@ -45,7 +45,9 @@ func WebAuthnRegister(ctx *context.Context) {
 		return
 	}
 
-	credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer))
+	credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
+		ResidentKey: protocol.ResidentKeyRequirementRequired,
+	}))
 	if err != nil {
 		ctx.ServerError("Unable to BeginRegistration", err)
 		return
diff --git a/routers/web/web.go b/routers/web/web.go
index 9f9a1bb0988e..d08e8da77285 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -535,6 +535,8 @@ func registerRoutes(m *web.Router) {
 		})
 		m.Group("/webauthn", func() {
 			m.Get("", auth.WebAuthn)
+			m.Get("/passkey/assertion", auth.WebAuthnPasskeyAssertion)
+			m.Post("/passkey/login", auth.WebAuthnPasskeyLogin)
 			m.Get("/assertion", auth.WebAuthnLoginAssertion)
 			m.Post("/assertion", auth.WebAuthnLoginAssertionPost)
 		})
diff --git a/templates/user/auth/signin_inner.tmpl b/templates/user/auth/signin_inner.tmpl
index 9872096fbc6a..51e0e3b98256 100644
--- a/templates/user/auth/signin_inner.tmpl
+++ b/templates/user/auth/signin_inner.tmpl
@@ -9,6 +9,8 @@
 	{{end}}
 </h4>
 <div class="ui attached segment">
+	{{template "user/auth/webauthn_error" .}}
+
 	<form class="ui form tw-max-w-2xl tw-m-auto" action="{{.SignInLink}}" method="post">
 	{{.CsrfTokenHtml}}
 	<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
@@ -49,6 +51,10 @@
 		</div>
 	{{end}}
 
+	<div class="field">
+		<a class="signin-passkey">{{ctx.Locale.Tr "auth.signin_passkey"}}</a>
+	</div>
+
 	{{if .OAuth2Providers}}
 	<div class="divider divider-text">
 		{{ctx.Locale.Tr "sign_in_or"}}
diff --git a/web_src/js/features/user-auth-webauthn.js b/web_src/js/features/user-auth-webauthn.js
index ea26614ba769..a317fee7e276 100644
--- a/web_src/js/features/user-auth-webauthn.js
+++ b/web_src/js/features/user-auth-webauthn.js
@@ -5,25 +5,88 @@ import {GET, POST} from '../modules/fetch.js';
 const {appSubUrl} = window.config;
 
 export async function initUserAuthWebAuthn() {
-  const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
-  if (!elPrompt) {
-    return;
-  }
-
   if (!detectWebAuthnSupport()) {
     return;
   }
 
-  const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
-  if (res.status !== 200) {
+  const elSignInPasskeyBtn = document.querySelector('.signin-passkey');
+  if (elSignInPasskeyBtn) {
+    elSignInPasskeyBtn.addEventListener('click', loginPasskey);
+  }
+
+  const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
+  if (elPrompt) {
+    login2FA();
+  }
+}
+
+async function loginPasskey() {
+  const res = await GET(`${appSubUrl}/user/webauthn/passkey/assertion`);
+  if (!res.ok) {
     webAuthnError('unknown');
     return;
   }
+
   const options = await res.json();
   options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
-  for (const cred of options.publicKey.allowCredentials) {
+  for (const cred of options.publicKey.allowCredentials ?? []) {
     cred.id = decodeURLEncodedBase64(cred.id);
   }
+
+  try {
+    const credential = await navigator.credentials.get({
+      publicKey: options.publicKey,
+    });
+
+    // Move data into Arrays in case it is super long
+    const authData = new Uint8Array(credential.response.authenticatorData);
+    const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
+    const rawId = new Uint8Array(credential.rawId);
+    const sig = new Uint8Array(credential.response.signature);
+    const userHandle = new Uint8Array(credential.response.userHandle);
+
+    const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
+      data: {
+        id: credential.id,
+        rawId: encodeURLEncodedBase64(rawId),
+        type: credential.type,
+        clientExtensionResults: credential.getClientExtensionResults(),
+        response: {
+          authenticatorData: encodeURLEncodedBase64(authData),
+          clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
+          signature: encodeURLEncodedBase64(sig),
+          userHandle: encodeURLEncodedBase64(userHandle),
+        },
+      },
+    });
+    if (res.status === 500) {
+      webAuthnError('unknown');
+      return;
+    } else if (!res.ok) {
+      webAuthnError('unable-to-process');
+      return;
+    }
+    const reply = await res.json();
+
+    window.location.href = reply?.redirect ?? `${appSubUrl}/`;
+  } catch (err) {
+    webAuthnError('general', err.message);
+  }
+}
+
+async function login2FA() {
+  const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
+  if (!res.ok) {
+    webAuthnError('unknown');
+    return;
+  }
+
+  const options = await res.json();
+  options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
+  for (const cred of options.publicKey.allowCredentials ?? []) {
+    cred.id = decodeURLEncodedBase64(cred.id);
+  }
+
   try {
     const credential = await navigator.credentials.get({
       publicKey: options.publicKey,
@@ -71,7 +134,7 @@ async function verifyAssertion(assertedCredential) {
   if (res.status === 500) {
     webAuthnError('unknown');
     return;
-  } else if (res.status !== 200) {
+  } else if (!res.ok) {
     webAuthnError('unable-to-process');
     return;
   }
@@ -167,7 +230,7 @@ async function webAuthnRegisterRequest() {
   if (res.status === 409) {
     webAuthnError('duplicated');
     return;
-  } else if (res.status !== 200) {
+  } else if (!res.ok) {
     webAuthnError('unknown');
     return;
   }