From 838dfa95ab622f4cfd5bc6c2f54d1cdf85050677 Mon Sep 17 00:00:00 2001 From: Ezra Developer Date: Sun, 7 Jun 2026 10:59:22 +0300 Subject: [PATCH] feat: add passkey plugin --- .gitignore | 20 ++- constants.go | 2 + examples/README.md | 2 + examples/passkey/go.mod | 32 +++++ examples/passkey/go.sum | 56 ++++++++ examples/passkey/index.html | 186 +++++++++++++++++++++++++ examples/passkey/main.go | 63 +++++++++ plugins/passkey/api.go | 29 ++++ plugins/passkey/authentication.go | 132 ++++++++++++++++++ plugins/passkey/constants.go | 44 ++++++ plugins/passkey/defaults.go | 17 +++ plugins/passkey/errors.go | 23 ++++ plugins/passkey/go.mod | 27 ++++ plugins/passkey/go.sum | 38 ++++++ plugins/passkey/handlers.go | 128 ++++++++++++++++++ plugins/passkey/management.go | 89 ++++++++++++ plugins/passkey/management_test.go | 120 +++++++++++++++++ plugins/passkey/plugin.go | 180 +++++++++++++++++++++++++ plugins/passkey/plugin_test.go | 121 +++++++++++++++++ plugins/passkey/registration.go | 209 +++++++++++++++++++++++++++++ plugins/passkey/schema_passkey.go | 150 +++++++++++++++++++++ plugins/passkey/state.go | 48 +++++++ plugins/passkey/state_test.go | 57 ++++++++ plugins/passkey/store.go | 87 ++++++++++++ plugins/passkey/testutil_test.go | 24 ++++ plugins/passkey/types.go | 92 +++++++++++++ plugins/passkey/webauthn_user.go | 87 ++++++++++++ 27 files changed, 2062 insertions(+), 1 deletion(-) create mode 100644 examples/passkey/go.mod create mode 100644 examples/passkey/go.sum create mode 100644 examples/passkey/index.html create mode 100644 examples/passkey/main.go create mode 100644 plugins/passkey/api.go create mode 100644 plugins/passkey/authentication.go create mode 100644 plugins/passkey/constants.go create mode 100644 plugins/passkey/defaults.go create mode 100644 plugins/passkey/errors.go create mode 100644 plugins/passkey/go.mod create mode 100644 plugins/passkey/go.sum create mode 100644 plugins/passkey/handlers.go create mode 100644 plugins/passkey/management.go create mode 100644 plugins/passkey/management_test.go create mode 100644 plugins/passkey/plugin.go create mode 100644 plugins/passkey/plugin_test.go create mode 100644 plugins/passkey/registration.go create mode 100644 plugins/passkey/schema_passkey.go create mode 100644 plugins/passkey/state.go create mode 100644 plugins/passkey/state_test.go create mode 100644 plugins/passkey/store.go create mode 100644 plugins/passkey/testutil_test.go create mode 100644 plugins/passkey/types.go create mode 100644 plugins/passkey/webauthn_user.go diff --git a/.gitignore b/.gitignore index 52449b3..523d927 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,22 @@ tmp/ limen-publish go.work go.work.sum -examples/kitchen/ \ No newline at end of file +examples/kitchen/ + +# Generated per-developer migration SQL produced by `limen generate migrations`. +# Each developer regenerates these against their own database; they should not be checked in. +examples/*/migrations/ + +# Locally-built CLI binaries from `cd cmd/limen && go build`. +cmd/limen/limen +cmd/limen/limen.exe + +# Local test artifacts (curl cookie jars, etc.). +cookies.txt + +# Claude Code local configuration. +.claude/ + +# Go build artifacts left in example dirs (e.g. `go build` without `-o`). +examples/*/*.exe +examples/*/*/*.exe \ No newline at end of file diff --git a/constants.go b/constants.go index 1bb2efe..234656f 100644 --- a/constants.go +++ b/constants.go @@ -37,6 +37,8 @@ const ( PluginOAuth PluginName = "oauth" PluginSessionJWT PluginName = "session-jwt" PluginMagicLink PluginName = "magic-link" + PluginEmailOTP PluginName = "email-otp" + PluginPasskey PluginName = "passkey" ) // ============================================================================ diff --git a/examples/README.md b/examples/README.md index de9ebeb..dc7d3c2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,7 @@ DATABASE_URL="postgres://..." go run ./examples/basic DATABASE_URL="postgres://..." go run ./examples/gin DATABASE_URL="postgres://..." GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... go run ./examples/oauth-google DATABASE_URL="postgres://..." go run ./examples/two-factor +DATABASE_URL="postgres://..." go run ./examples/passkey DATABASE_URL="postgres://..." go run ./examples/adapters/gorm DATABASE_URL="postgres://..." go run ./examples/adapters/sql ``` @@ -29,6 +30,7 @@ DATABASE_URL="postgres://..." go run ./examples/adapters/sql | `gin` | GORM | credential-password | -- | | `oauth-google` | GORM | OAuth (Google) | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | | `two-factor` | GORM | credential-password, two-factor | -- | +| `passkey` | `database/sql` | credential-password, passkey | -- | | `adapters/gorm` | GORM | credential-password | -- | | `adapters/sql` | `database/sql` | credential-password | -- | diff --git a/examples/passkey/go.mod b/examples/passkey/go.mod new file mode 100644 index 0000000..6da2ebb --- /dev/null +++ b/examples/passkey/go.mod @@ -0,0 +1,32 @@ +module example/passkey + +go 1.25.0 + +require ( + github.com/lib/pq v1.10.9 + github.com/thecodearcher/limen v0.1.1 + github.com/thecodearcher/limen/adapters/sql v0.0.1 + github.com/thecodearcher/limen/plugins/credential-password v0.0.1 + github.com/thecodearcher/limen/plugins/passkey v0.0.0 +) + +require ( + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-webauthn/webauthn v0.13.4 // indirect + github.com/go-webauthn/x v0.1.23 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect + github.com/google/go-tpm v0.9.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) + +replace ( + github.com/thecodearcher/limen => ../.. + github.com/thecodearcher/limen/adapters/sql => ../../adapters/sql + github.com/thecodearcher/limen/plugins/credential-password => ../../plugins/credential-password + github.com/thecodearcher/limen/plugins/passkey => ../../plugins/passkey +) diff --git a/examples/passkey/go.sum b/examples/passkey/go.sum new file mode 100644 index 0000000..875294d --- /dev/null +++ b/examples/passkey/go.sum @@ -0,0 +1,56 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ= +github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= +github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI= +github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/examples/passkey/index.html b/examples/passkey/index.html new file mode 100644 index 0000000..c596912 --- /dev/null +++ b/examples/passkey/index.html @@ -0,0 +1,186 @@ + + + + + Limen Passkey Demo + + + +

Limen Passkey Demo

+

+ Bare-bones HTML to exercise the passkey plugin end-to-end. Use Chrome + DevTools → WebAuthn panel to enable a virtual authenticator so + no physical key is required. +

+ +

1. Create an account or sign in

+

Passkey registration requires an authenticated session. Use email + password to get one.

+ + +
+ + +
+
+ +

2. Register a passkey

+ + +
+ +

3. Sign in with a passkey

+ + +
+ +

4. Manage passkeys

+ +

+
+    
+  
+
diff --git a/examples/passkey/main.go b/examples/passkey/main.go
new file mode 100644
index 0000000..915b350
--- /dev/null
+++ b/examples/passkey/main.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+	"database/sql"
+	"embed"
+	"log"
+	"net/http"
+	"os"
+
+	_ "github.com/lib/pq"
+
+	"github.com/thecodearcher/limen"
+	sqladapter "github.com/thecodearcher/limen/adapters/sql"
+	credentialpassword "github.com/thecodearcher/limen/plugins/credential-password"
+	"github.com/thecodearcher/limen/plugins/passkey"
+)
+
+//go:embed index.html
+var staticFS embed.FS
+
+func main() {
+	dsn := os.Getenv("DATABASE_URL")
+	if dsn == "" {
+		log.Fatal("set DATABASE_URL")
+	}
+
+	db, err := sql.Open("postgres", dsn)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer db.Close()
+
+	auth, err := limen.New(&limen.Config{
+		BaseURL:  "http://localhost:8080",
+		Database: sqladapter.NewPostgreSQL(db),
+		Secret:   []byte("0123456789abcdef0123456789abcdef"),
+		HTTP:     limen.NewDefaultHTTPConfig(limen.WithHTTPBasePath("/api/auth")),
+		CLI:      &limen.CLIConfig{Enabled: true},
+		Plugins: []limen.Plugin{
+			// We pair passkey with credential-password so testers can
+			// create an account first, then register a passkey against
+			// that account.
+			credentialpassword.New(),
+			passkey.New(
+				passkey.WithRPID("localhost"),
+				passkey.WithOrigins("http://localhost:8080"),
+				passkey.WithRPName("Limen Passkey Demo"),
+			),
+		},
+	})
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	mux := http.NewServeMux()
+	mux.Handle("/api/auth/", auth.Handler())
+	mux.Handle("/", http.FileServer(http.FS(staticFS)))
+
+	log.Println("passkey example listening on http://localhost:8080")
+	log.Println("  open http://localhost:8080 in Chrome and use the virtual authenticator")
+	log.Println("  (DevTools → ... → More tools → WebAuthn → Enable virtual authenticator)")
+	log.Fatal(http.ListenAndServe(":8080", mux))
+}
diff --git a/plugins/passkey/api.go b/plugins/passkey/api.go
new file mode 100644
index 0000000..afb5d53
--- /dev/null
+++ b/plugins/passkey/api.go
@@ -0,0 +1,29 @@
+package passkey
+
+import (
+	"context"
+	"io"
+	"net/http"
+
+	"github.com/go-webauthn/webauthn/protocol"
+
+	"github.com/thecodearcher/limen"
+)
+
+// API is the public interface for the passkey plugin. Obtain a
+// type-safe reference via Use().
+type API interface {
+	BeginRegistration(ctx context.Context, r *http.Request, w http.ResponseWriter, nameHint string) (*protocol.CredentialCreation, error)
+	FinishRegistration(ctx context.Context, r *http.Request, body io.Reader) (*Passkey, error)
+	BeginAuthentication(ctx context.Context, w http.ResponseWriter) (*protocol.CredentialAssertion, error)
+	FinishAuthentication(ctx context.Context, r *http.Request, body io.Reader) (*limen.AuthenticationResult, error)
+	ListPasskeys(ctx context.Context, userID any) ([]Passkey, error)
+	DeletePasskey(ctx context.Context, userID, passkeyID any) error
+	UpdatePasskey(ctx context.Context, userID, passkeyID any, newName string) (*Passkey, error)
+}
+
+// Use returns the passkey plugin's API from a Limen instance. Panics
+// if the plugin was not registered in Config.Plugins.
+func Use(a *limen.Limen) API {
+	return limen.Use[API](a, limen.PluginPasskey)
+}
diff --git a/plugins/passkey/authentication.go b/plugins/passkey/authentication.go
new file mode 100644
index 0000000..b14756e
--- /dev/null
+++ b/plugins/passkey/authentication.go
@@ -0,0 +1,132 @@
+package passkey
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"io"
+	"net/http"
+
+	"github.com/go-webauthn/webauthn/protocol"
+	"github.com/go-webauthn/webauthn/webauthn"
+
+	"github.com/thecodearcher/limen"
+)
+
+// BeginAuthentication kicks off a passkey sign-in. We use the
+// "discoverable credential" flow: no user identifier is required up
+// front; the client's authenticator presents a credential picker and
+// the server resolves the user from the returned credential ID. This
+// is what every modern passkey UX expects.
+func (p *passkeyPlugin) BeginAuthentication(ctx context.Context, w http.ResponseWriter) (*protocol.CredentialAssertion, error) {
+	options, sessionData, err := p.webauthn.BeginDiscoverableLogin()
+	if err != nil {
+		return nil, err
+	}
+
+	if err := p.storeChallenge(ctx, w, &challengeState{
+		Ceremony: CeremonyAuthentication,
+		Session:  *sessionData,
+	}); err != nil {
+		return nil, err
+	}
+
+	return options, nil
+}
+
+// FinishAuthentication validates the assertion returned by the
+// client's authenticator, looks up the registered credential, updates
+// its sign counter, and returns the authenticated user.
+func (p *passkeyPlugin) FinishAuthentication(ctx context.Context, r *http.Request, body io.Reader) (*limen.AuthenticationResult, error) {
+	state, err := p.consumeChallenge(ctx, r)
+	if err != nil {
+		return nil, err
+	}
+	if state.Ceremony != CeremonyAuthentication {
+		return nil, ErrChallengeMismatch
+	}
+
+	parsed, err := protocol.ParseCredentialRequestResponseBody(body)
+	if err != nil {
+		return nil, ErrInvalidResponse
+	}
+
+	// Discoverable login means the authenticator picked which credential
+	// to present; we resolve the user via the handler the library calls
+	// with the rawID + userHandle from the assertion.
+	var resolvedOwner *limen.User
+	var resolvedRecord *PasskeyRecord
+	handler := func(rawID, userHandle []byte) (webauthn.User, error) {
+		credIDB64 := base64.RawURLEncoding.EncodeToString(rawID)
+		rec, err := p.findByCredentialID(ctx, credIDB64)
+		if err != nil {
+			return nil, err
+		}
+		owner, err := p.findUserByID(ctx, rec.UserID)
+		if err != nil {
+			return nil, err
+		}
+		waUser, err := p.toWebAuthnUser(owner, []*PasskeyRecord{rec})
+		if err != nil {
+			return nil, err
+		}
+		resolvedOwner = owner
+		resolvedRecord = rec
+		return waUser, nil
+	}
+
+	credential, err := p.webauthn.ValidateDiscoverableLogin(handler, state.Session, parsed)
+	if err != nil {
+		if errors.Is(err, limen.ErrRecordNotFound) {
+			return nil, ErrPasskeyNotFound
+		}
+		return nil, ErrAuthenticationFailed
+	}
+	if resolvedOwner == nil || resolvedRecord == nil {
+		return nil, ErrAuthenticationFailed
+	}
+	rec := resolvedRecord
+	owner := resolvedOwner
+
+	// Update sign counter / backup state on every successful auth.
+	rec.Counter = int64(credential.Authenticator.SignCount)
+	rec.BackedUp = credential.Flags.BackupState
+	if err := p.core.Update(ctx, p.passkeySchema, rec, []limen.Where{
+		limen.Eq(p.passkeySchema.GetIDField(), rec.ID),
+	}); err != nil {
+		return nil, err
+	}
+
+	return &limen.AuthenticationResult{User: owner}, nil
+}
+
+func (p *passkeyPlugin) findUserByID(ctx context.Context, id any) (*limen.User, error) {
+	res, err := p.core.FindOne(ctx, p.userSchema, []limen.Where{
+		limen.Eq(p.userSchema.GetIDField(), id),
+	}, nil)
+	if err != nil {
+		return nil, err
+	}
+	return res.(*limen.User), nil
+}
+
+// discoverableHandler matches the signature used by ValidateDiscoverable
+// in newer go-webauthn versions; we keep it for completeness even
+// though the discoverable login path used here resolves the user via
+// FinishAuthentication's explicit credential lookup.
+//
+//nolint:unused // kept for future use when go-webauthn formalizes the API
+func (p *passkeyPlugin) discoverableHandler(ctx context.Context) webauthn.DiscoverableUserHandler {
+	return func(rawID, userHandle []byte) (webauthn.User, error) {
+		credIDB64 := base64.RawURLEncoding.EncodeToString(rawID)
+		rec, err := p.findByCredentialID(ctx, credIDB64)
+		if err != nil {
+			return nil, err
+		}
+		owner, err := p.findUserByID(ctx, rec.UserID)
+		if err != nil {
+			return nil, err
+		}
+		return p.toWebAuthnUser(owner, []*PasskeyRecord{rec})
+	}
+}
diff --git a/plugins/passkey/constants.go b/plugins/passkey/constants.go
new file mode 100644
index 0000000..a423ef2
--- /dev/null
+++ b/plugins/passkey/constants.go
@@ -0,0 +1,44 @@
+package passkey
+
+import (
+	"time"
+
+	"github.com/thecodearcher/limen"
+)
+
+const (
+	// PasskeySchemaTableName is the physical table name used for stored credentials.
+	PasskeySchemaTableName limen.SchemaTableName = "passkeys"
+
+	PasskeySchemaUserIDField       limen.SchemaField = "user_id"
+	PasskeySchemaNameField         limen.SchemaField = "name"
+	PasskeySchemaCredentialIDField limen.SchemaField = "credential_id"
+	PasskeySchemaPublicKeyField    limen.SchemaField = "public_key"
+	PasskeySchemaCounterField      limen.SchemaField = "counter"
+	PasskeySchemaDeviceTypeField   limen.SchemaField = "device_type"
+	PasskeySchemaBackedUpField     limen.SchemaField = "backed_up"
+	PasskeySchemaTransportsField   limen.SchemaField = "transports"
+	PasskeySchemaAAGUIDField       limen.SchemaField = "aaguid"
+)
+
+const (
+	// passkeyChallengeAction is the verification action used to persist a
+	// challenge while the client completes a WebAuthn ceremony.
+	passkeyChallengeAction = "passkey_challenge" // #nosec G101 -- action name, not a secret
+
+	defaultRPName                = "Limen App"
+	defaultChallengeCookieName   = "limen_passkey_challenge"
+	defaultChallengeExpiration   = 5 * time.Minute
+	defaultUserHandleByteLength  = 32
+	verificationTokenByteLength  = 32
+)
+
+// CeremonyKind names the two WebAuthn ceremonies. It is persisted in the
+// challenge state so a registration cookie cannot be replayed against an
+// authentication endpoint, or vice versa.
+type CeremonyKind string
+
+const (
+	CeremonyRegistration   CeremonyKind = "registration"
+	CeremonyAuthentication CeremonyKind = "authentication"
+)
diff --git a/plugins/passkey/defaults.go b/plugins/passkey/defaults.go
new file mode 100644
index 0000000..97154cc
--- /dev/null
+++ b/plugins/passkey/defaults.go
@@ -0,0 +1,17 @@
+package passkey
+
+import "github.com/go-webauthn/webauthn/protocol"
+
+// authSelectionOrDefault returns the configured AuthenticatorSelection
+// criteria or a sensible default that mirrors better-auth: prefer a
+// resident (passkey-capable) key and prefer user verification, but
+// don't make either mandatory unless the caller opts in.
+func authSelectionOrDefault(sel *protocol.AuthenticatorSelection) protocol.AuthenticatorSelection {
+	if sel != nil {
+		return *sel
+	}
+	return protocol.AuthenticatorSelection{
+		ResidentKey:      protocol.ResidentKeyRequirementPreferred,
+		UserVerification: protocol.VerificationPreferred,
+	}
+}
diff --git a/plugins/passkey/errors.go b/plugins/passkey/errors.go
new file mode 100644
index 0000000..4b20753
--- /dev/null
+++ b/plugins/passkey/errors.go
@@ -0,0 +1,23 @@
+package passkey
+
+import (
+	"net/http"
+
+	"github.com/thecodearcher/limen"
+)
+
+var (
+	ErrSessionRequired      = limen.NewLimenError("passkey registration requires an authenticated session", http.StatusUnauthorized, nil)
+	ErrChallengeNotFound    = limen.NewLimenError("passkey challenge not found", http.StatusBadRequest, nil)
+	ErrChallengeMismatch    = limen.NewLimenError("passkey challenge does not match", http.StatusBadRequest, nil)
+	ErrInvalidResponse      = limen.NewLimenError("invalid passkey response", http.StatusBadRequest, nil)
+	ErrRegistrationFailed   = limen.NewLimenError("failed to verify passkey registration", http.StatusBadRequest, nil)
+	ErrAuthenticationFailed = limen.NewLimenError("passkey authentication failed", http.StatusUnauthorized, nil)
+	ErrPasskeyNotFound      = limen.NewLimenError("passkey not found", http.StatusNotFound, nil)
+	ErrPasskeyAlreadyExists = limen.NewLimenError("passkey already registered", http.StatusConflict, nil)
+	ErrOriginRequired       = limen.NewLimenError("at least one allowed origin is required", http.StatusInternalServerError, nil)
+	ErrIDRequired           = limen.NewLimenError("id is required", http.StatusUnprocessableEntity, nil)
+	ErrNameRequired         = limen.NewLimenError("name is required", http.StatusUnprocessableEntity, nil)
+	ErrForbidden            = limen.NewLimenError("you are not allowed to perform this action on this passkey", http.StatusForbidden, nil)
+	ErrUnauthorized         = limen.NewLimenError("authentication required", http.StatusUnauthorized, nil)
+)
diff --git a/plugins/passkey/go.mod b/plugins/passkey/go.mod
new file mode 100644
index 0000000..b7c2df2
--- /dev/null
+++ b/plugins/passkey/go.mod
@@ -0,0 +1,27 @@
+module github.com/thecodearcher/limen/plugins/passkey
+
+go 1.25.0
+
+require (
+	github.com/go-webauthn/webauthn v0.13.4
+	github.com/stretchr/testify v1.11.1
+	github.com/thecodearcher/limen v0.1.1
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+	github.com/go-webauthn/x v0.1.23 // indirect
+	github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
+	github.com/google/go-tpm v0.9.5 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/kr/text v0.2.0 // indirect
+	github.com/mitchellh/mapstructure v1.5.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
+	golang.org/x/crypto v0.48.0 // indirect
+	golang.org/x/sys v0.41.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+replace github.com/thecodearcher/limen => ../..
diff --git a/plugins/passkey/go.sum b/plugins/passkey/go.sum
new file mode 100644
index 0000000..dc20349
--- /dev/null
+++ b/plugins/passkey/go.sum
@@ -0,0 +1,38 @@
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
+github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
+github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
+github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
+github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
+github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
+github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/plugins/passkey/handlers.go b/plugins/passkey/handlers.go
new file mode 100644
index 0000000..bfe6d5b
--- /dev/null
+++ b/plugins/passkey/handlers.go
@@ -0,0 +1,128 @@
+package passkey
+
+import (
+	"net/http"
+
+	"github.com/thecodearcher/limen"
+)
+
+type passkeyHandlers struct {
+	plugin    *passkeyPlugin
+	httpCore  *limen.LimenHTTPCore
+	responder *limen.Responder
+}
+
+func newPasskeyHandlers(plugin *passkeyPlugin, httpCore *limen.LimenHTTPCore) *passkeyHandlers {
+	return &passkeyHandlers{
+		plugin:    plugin,
+		httpCore:  httpCore,
+		responder: httpCore.Responder,
+	}
+}
+
+func (h *passkeyHandlers) GenerateRegistrationOptions(w http.ResponseWriter, r *http.Request) {
+	name := r.URL.Query().Get("name")
+	options, err := h.plugin.BeginRegistration(r.Context(), r, w, name)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	h.responder.JSON(w, r, http.StatusOK, options)
+}
+
+func (h *passkeyHandlers) VerifyRegistration(w http.ResponseWriter, r *http.Request) {
+	if r.Body == nil {
+		h.responder.Error(w, r, ErrInvalidResponse)
+		return
+	}
+	defer r.Body.Close()
+	passkey, err := h.plugin.FinishRegistration(r.Context(), r, r.Body)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	h.responder.JSON(w, r, http.StatusOK, passkey)
+}
+
+func (h *passkeyHandlers) GenerateAuthenticationOptions(w http.ResponseWriter, r *http.Request) {
+	options, err := h.plugin.BeginAuthentication(r.Context(), w)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	h.responder.JSON(w, r, http.StatusOK, options)
+}
+
+func (h *passkeyHandlers) VerifyAuthentication(w http.ResponseWriter, r *http.Request) {
+	if r.Body == nil {
+		h.responder.Error(w, r, ErrInvalidResponse)
+		return
+	}
+	defer r.Body.Close()
+	result, err := h.plugin.FinishAuthentication(r.Context(), r, r.Body)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	sessionResult, err := h.plugin.core.CreateSession(r.Context(), r, w, result)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	h.responder.SessionResponse(w, r, h.plugin.core, result, sessionResult)
+}
+
+func (h *passkeyHandlers) ListPasskeys(w http.ResponseWriter, r *http.Request) {
+	session, err := requireValidatedSession(r)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	passkeys, err := h.plugin.ListPasskeys(r.Context(), session.User.ID)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	h.responder.JSON(w, r, http.StatusOK, passkeys)
+}
+
+func (h *passkeyHandlers) DeletePasskey(w http.ResponseWriter, r *http.Request) {
+	session, err := requireValidatedSession(r)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	body := limen.ValidateJSON(w, r, h.responder, func(v *limen.Validator, data map[string]any) *limen.Validator {
+		return v.RequiredString("id", data["id"])
+	})
+	if body == nil {
+		return
+	}
+	if err := h.plugin.DeletePasskey(r.Context(), session.User.ID, body["id"]); err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	h.responder.JSON(w, r, http.StatusOK, map[string]any{"success": true})
+}
+
+func (h *passkeyHandlers) UpdatePasskey(w http.ResponseWriter, r *http.Request) {
+	session, err := requireValidatedSession(r)
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	body := limen.ValidateJSON(w, r, h.responder, func(v *limen.Validator, data map[string]any) *limen.Validator {
+		return v.
+			RequiredString("id", data["id"]).
+			RequiredString("name", data["name"])
+	})
+	if body == nil {
+		return
+	}
+	updated, err := h.plugin.UpdatePasskey(r.Context(), session.User.ID, body["id"], body["name"].(string))
+	if err != nil {
+		h.responder.Error(w, r, err)
+		return
+	}
+	h.responder.JSON(w, r, http.StatusOK, updated)
+}
diff --git a/plugins/passkey/management.go b/plugins/passkey/management.go
new file mode 100644
index 0000000..f13c393
--- /dev/null
+++ b/plugins/passkey/management.go
@@ -0,0 +1,89 @@
+package passkey
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/thecodearcher/limen"
+)
+
+// ListPasskeys returns every credential registered for the given user,
+// newest first.
+func (p *passkeyPlugin) ListPasskeys(ctx context.Context, userID any) ([]Passkey, error) {
+	recs, err := p.listForUser(ctx, userID)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]Passkey, 0, len(recs))
+	for _, rec := range recs {
+		out = append(out, publicPasskey(rec))
+	}
+	return out, nil
+}
+
+// DeletePasskey removes a single credential. The caller must own the
+// credential; cross-user deletion attempts return ErrForbidden so we
+// don't leak the existence of credentials registered to other users.
+func (p *passkeyPlugin) DeletePasskey(ctx context.Context, userID, passkeyID any) error {
+	rec, err := p.findByID(ctx, passkeyID)
+	if err != nil {
+		if errors.Is(err, limen.ErrRecordNotFound) {
+			return ErrPasskeyNotFound
+		}
+		return err
+	}
+	if !sameID(rec.UserID, userID) {
+		return ErrForbidden
+	}
+	return p.core.Delete(ctx, p.passkeySchema, []limen.Where{
+		limen.Eq(p.passkeySchema.GetIDField(), rec.ID),
+	})
+}
+
+// UpdatePasskey renames a credential. Ownership is enforced the same
+// way as DeletePasskey.
+func (p *passkeyPlugin) UpdatePasskey(ctx context.Context, userID, passkeyID any, newName string) (*Passkey, error) {
+	rec, err := p.findByID(ctx, passkeyID)
+	if err != nil {
+		if errors.Is(err, limen.ErrRecordNotFound) {
+			return nil, ErrPasskeyNotFound
+		}
+		return nil, err
+	}
+	if !sameID(rec.UserID, userID) {
+		return nil, ErrForbidden
+	}
+
+	rec.Name = newName
+	rec.UpdatedAt = time.Now()
+	if err := p.core.Update(ctx, p.passkeySchema, rec, []limen.Where{
+		limen.Eq(p.passkeySchema.GetIDField(), rec.ID),
+	}); err != nil {
+		return nil, err
+	}
+
+	refreshed, err := p.findByID(ctx, rec.ID)
+	if err != nil {
+		return nil, err
+	}
+	out := publicPasskey(refreshed)
+	return &out, nil
+}
+
+// publicPasskey converts a storage record into the caller-facing shape
+// returned by every passkey endpoint.
+func publicPasskey(rec *PasskeyRecord) Passkey {
+	return Passkey{
+		ID:           rec.ID,
+		UserID:       rec.UserID,
+		Name:         rec.Name,
+		CredentialID: rec.CredentialIDBase64,
+		DeviceType:   rec.DeviceType,
+		BackedUp:     rec.BackedUp,
+		Transports:   rec.Transports,
+		AAGUID:       rec.AAGUID,
+		CreatedAt:    rec.CreatedAt,
+		UpdatedAt:    rec.UpdatedAt,
+	}
+}
diff --git a/plugins/passkey/management_test.go b/plugins/passkey/management_test.go
new file mode 100644
index 0000000..eb54183
--- /dev/null
+++ b/plugins/passkey/management_test.go
@@ -0,0 +1,120 @@
+package passkey
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/thecodearcher/limen"
+)
+
+// seedPasskey directly inserts a row into the passkeys table so tests
+// can exercise List/Delete/Update without going through the WebAuthn
+// registration ceremony (which needs a real browser).
+func seedPasskey(t *testing.T, plugin *passkeyPlugin, userID any, credID, name string) *PasskeyRecord {
+	t.Helper()
+	now := time.Now()
+	rec := &PasskeyRecord{
+		UserID:             userID,
+		Name:               name,
+		CredentialIDBase64: credID,
+		PublicKeyBase64:    "AAAA",
+		Counter:            0,
+		DeviceType:         "multiDevice",
+		BackedUp:           true,
+		Transports:         "internal",
+		CreatedAt:          now,
+		UpdatedAt:          now,
+	}
+	require.NoError(t, plugin.core.Create(context.Background(), plugin.passkeySchema, rec, nil))
+	stored, err := plugin.findByCredentialID(context.Background(), credID)
+	require.NoError(t, err)
+	return stored
+}
+
+func TestListPasskeys_ReturnsCallerCredentialsOnly(t *testing.T) {
+	t.Parallel()
+
+	l, plugin := newTestLimenAndPlugin(t)
+	alice := limen.SeedTestUser(t, l, "alice@test.com")
+	bob := limen.SeedTestUser(t, l, "bob@test.com")
+
+	seedPasskey(t, plugin, alice.ID, "cred-a-1", "Alice MacBook")
+	seedPasskey(t, plugin, alice.ID, "cred-a-2", "Alice iPhone")
+	seedPasskey(t, plugin, bob.ID, "cred-b-1", "Bob Laptop")
+
+	got, err := plugin.ListPasskeys(context.Background(), alice.ID)
+	require.NoError(t, err)
+	assert.Len(t, got, 2)
+	for _, pk := range got {
+		assert.Equal(t, alice.ID, pk.UserID)
+	}
+}
+
+func TestDeletePasskey_OwnerCanDelete(t *testing.T) {
+	t.Parallel()
+
+	l, plugin := newTestLimenAndPlugin(t)
+	alice := limen.SeedTestUser(t, l, "owner@test.com")
+	rec := seedPasskey(t, plugin, alice.ID, "cred-owned", "Owned")
+
+	require.NoError(t, plugin.DeletePasskey(context.Background(), alice.ID, rec.ID))
+
+	list, err := plugin.ListPasskeys(context.Background(), alice.ID)
+	require.NoError(t, err)
+	assert.Empty(t, list)
+}
+
+func TestDeletePasskey_NonOwnerForbidden(t *testing.T) {
+	t.Parallel()
+
+	l, plugin := newTestLimenAndPlugin(t)
+	alice := limen.SeedTestUser(t, l, "owner@test.com")
+	bob := limen.SeedTestUser(t, l, "intruder@test.com")
+	rec := seedPasskey(t, plugin, alice.ID, "cred-protected", "Alice key")
+
+	err := plugin.DeletePasskey(context.Background(), bob.ID, rec.ID)
+	assert.ErrorIs(t, err, ErrForbidden)
+
+	// Row must still exist.
+	list, err := plugin.ListPasskeys(context.Background(), alice.ID)
+	require.NoError(t, err)
+	assert.Len(t, list, 1)
+}
+
+func TestDeletePasskey_NotFound(t *testing.T) {
+	t.Parallel()
+
+	l, plugin := newTestLimenAndPlugin(t)
+	alice := limen.SeedTestUser(t, l, "u@test.com")
+
+	err := plugin.DeletePasskey(context.Background(), alice.ID, 999999)
+	assert.ErrorIs(t, err, ErrPasskeyNotFound)
+}
+
+func TestUpdatePasskey_RenamesOwnedCredential(t *testing.T) {
+	t.Parallel()
+
+	l, plugin := newTestLimenAndPlugin(t)
+	alice := limen.SeedTestUser(t, l, "rename@test.com")
+	rec := seedPasskey(t, plugin, alice.ID, "cred-rename", "Old name")
+
+	updated, err := plugin.UpdatePasskey(context.Background(), alice.ID, rec.ID, "Brand new name")
+	require.NoError(t, err)
+	assert.Equal(t, "Brand new name", updated.Name)
+}
+
+func TestUpdatePasskey_NonOwnerForbidden(t *testing.T) {
+	t.Parallel()
+
+	l, plugin := newTestLimenAndPlugin(t)
+	alice := limen.SeedTestUser(t, l, "owner@test.com")
+	bob := limen.SeedTestUser(t, l, "intruder@test.com")
+	rec := seedPasskey(t, plugin, alice.ID, "cred-protected2", "Original")
+
+	_, err := plugin.UpdatePasskey(context.Background(), bob.ID, rec.ID, "Hijacked")
+	assert.ErrorIs(t, err, ErrForbidden)
+}
diff --git a/plugins/passkey/plugin.go b/plugins/passkey/plugin.go
new file mode 100644
index 0000000..128ef73
--- /dev/null
+++ b/plugins/passkey/plugin.go
@@ -0,0 +1,180 @@
+// Package passkey provides WebAuthn / passkey authentication for Limen.
+//
+// The plugin implements the four ceremonies required for a complete
+// passkey experience plus credential management:
+//
+//   GET  /passkey/generate-register-options   - kick off a registration
+//   POST /passkey/verify-registration         - finish registration
+//   GET  /passkey/generate-authenticate-options - kick off an authentication
+//   POST /passkey/verify-authentication       - finish authentication, sets session
+//   GET  /passkey/list                        - list the current user's passkeys
+//   POST /passkey/delete                      - delete a passkey by id
+//   POST /passkey/update                      - rename a passkey
+//
+// It is modelled after better-auth's passkey plugin and uses
+// github.com/go-webauthn/webauthn for the protocol-level work.
+package passkey
+
+import (
+	"fmt"
+	"net/url"
+	"time"
+
+	"github.com/go-webauthn/webauthn/webauthn"
+
+	"github.com/thecodearcher/limen"
+)
+
+type passkeyPlugin struct {
+	core         *limen.LimenCore
+	config       *config
+	userSchema   *limen.UserSchema
+	passkeySchema *passkeySchema
+	verifSchema  *limen.VerificationSchema
+	dbAction     *limen.DatabaseActionHelper
+	webauthn     *webauthn.WebAuthn
+}
+
+// New returns a passkey plugin configured with sensible defaults. At
+// least one of RPID or origins should be set in production; the
+// defaults are tuned for "localhost" development.
+func New(opts ...ConfigOption) *passkeyPlugin {
+	cfg := &config{
+		rpName:              defaultRPName,
+		challengeExpiration: defaultChallengeExpiration,
+		challengeCookieName: defaultChallengeCookieName,
+		requireSessionToReg: true,
+	}
+	for _, opt := range opts {
+		opt(cfg)
+	}
+	return &passkeyPlugin{config: cfg}
+}
+
+func (p *passkeyPlugin) Name() limen.PluginName { return limen.PluginPasskey }
+
+func (p *passkeyPlugin) Initialize(core *limen.LimenCore) error {
+	p.core = core
+	p.userSchema = core.Schema.User
+	p.verifSchema = core.Schema.Verification
+	p.dbAction = core.DBAction
+
+	if p.config == nil {
+		return fmt.Errorf("passkey: config is required")
+	}
+	if p.config.challengeExpiration <= 0 {
+		return fmt.Errorf("passkey: challenge expiration must be positive")
+	}
+
+	// Derive RP origins + RP ID from Limen's BaseURL when unset.
+	baseURL := core.GetBaseURL()
+	if len(p.config.origins) == 0 && baseURL != "" {
+		p.config.origins = []string{baseURL}
+	}
+	if p.config.rpID == "" {
+		p.config.rpID = inferRPID(baseURL)
+	}
+	if len(p.config.origins) == 0 {
+		return ErrOriginRequired
+	}
+
+	w, err := webauthn.New(&webauthn.Config{
+		RPDisplayName:         p.config.rpName,
+		RPID:                  p.config.rpID,
+		RPOrigins:             p.config.origins,
+		AuthenticatorSelection: authSelectionOrDefault(p.config.authenticatorSelect),
+		Timeouts: webauthn.TimeoutsConfig{
+			Login: webauthn.TimeoutConfig{
+				Enforce:    true,
+				Timeout:    p.config.challengeExpiration,
+				TimeoutUVD: p.config.challengeExpiration,
+			},
+			Registration: webauthn.TimeoutConfig{
+				Enforce:    true,
+				Timeout:    p.config.challengeExpiration,
+				TimeoutUVD: p.config.challengeExpiration,
+			},
+		},
+	})
+	if err != nil {
+		return fmt.Errorf("passkey: webauthn init: %w", err)
+	}
+	p.webauthn = w
+
+	return nil
+}
+
+func (p *passkeyPlugin) PluginHTTPConfig() limen.PluginHTTPConfig {
+	return limen.PluginHTTPConfig{
+		BasePath: "/passkey",
+		RateLimitRules: []*limen.RateLimitRule{
+			limen.NewRateLimitRule("/generate-register-options", 10, time.Minute),
+			limen.NewRateLimitRule("/verify-registration", 10, time.Minute),
+			limen.NewRateLimitRule("/generate-authenticate-options", 20, time.Minute),
+			limen.NewRateLimitRule("/verify-authentication", 20, time.Minute),
+		},
+	}
+}
+
+func (p *passkeyPlugin) RegisterRoutes(httpCore *limen.LimenHTTPCore, routeBuilder *limen.RouteBuilder) {
+	h := newPasskeyHandlers(p, httpCore)
+	routeBuilder.ProtectedGET("/generate-register-options", "passkey-gen-register", h.GenerateRegistrationOptions)
+	routeBuilder.ProtectedPOST("/verify-registration", "passkey-verify-register", h.VerifyRegistration)
+	// Authentication endpoints do NOT require a session — they create one.
+	routeBuilder.GET("/generate-authenticate-options", "passkey-gen-auth", h.GenerateAuthenticationOptions)
+	routeBuilder.POST("/verify-authentication", "passkey-verify-auth", h.VerifyAuthentication)
+	routeBuilder.ProtectedGET("/list", "passkey-list", h.ListPasskeys)
+	routeBuilder.ProtectedPOST("/delete", "passkey-delete", h.DeletePasskey)
+	routeBuilder.ProtectedPOST("/update", "passkey-update", h.UpdatePasskey)
+}
+
+func (p *passkeyPlugin) GetSchemas(schema *limen.SchemaConfig) []limen.SchemaIntrospector {
+	s := newPasskeySchema()
+	p.passkeySchema = s
+
+	table := limen.NewSchemaDefinitionForTable(
+		limen.SchemaName(PasskeySchemaTableName),
+		PasskeySchemaTableName,
+		s,
+		limen.WithSchemaIDField(schema),
+		limen.WithSchemaField(string(PasskeySchemaUserIDField), schema.GetIDColumnType()),
+		limen.WithSchemaField(string(PasskeySchemaNameField), limen.ColumnTypeString, limen.WithNullable(true)),
+		limen.WithSchemaField(string(PasskeySchemaCredentialIDField), limen.ColumnTypeText),
+		limen.WithSchemaField(string(PasskeySchemaPublicKeyField), limen.ColumnTypeText),
+		limen.WithSchemaField(string(PasskeySchemaCounterField), limen.ColumnTypeInt64, limen.WithDefaultValue("0")),
+		limen.WithSchemaField(string(PasskeySchemaDeviceTypeField), limen.ColumnTypeString),
+		limen.WithSchemaField(string(PasskeySchemaBackedUpField), limen.ColumnTypeBool, limen.WithDefaultValue("false")),
+		limen.WithSchemaField(string(PasskeySchemaTransportsField), limen.ColumnTypeString, limen.WithNullable(true)),
+		limen.WithSchemaField(string(PasskeySchemaAAGUIDField), limen.ColumnTypeString, limen.WithNullable(true)),
+		limen.WithSchemaField(string(limen.SchemaCreatedAtField), limen.ColumnTypeTime, limen.WithDefaultValue(string(limen.DatabaseDefaultValueNow))),
+		limen.WithSchemaField(string(limen.SchemaUpdatedAtField), limen.ColumnTypeTime),
+		limen.WithSchemaForeignKey(limen.ForeignKeyDefinition{
+			Name:             "fk_passkeys_users_user_id",
+			Column:           PasskeySchemaUserIDField,
+			ReferencedSchema: limen.CoreSchemaUsers,
+			ReferencedField:  limen.SchemaIDField,
+			OnDelete:         limen.FKActionCascade,
+			OnUpdate:         limen.FKActionCascade,
+		}),
+		limen.WithSchemaUniqueIndex("idx_passkeys_credential_id", []limen.SchemaField{PasskeySchemaCredentialIDField}),
+	)
+
+	return []limen.SchemaIntrospector{table}
+}
+
+// inferRPID derives the WebAuthn Relying Party ID from a base URL.
+// Defaults to "localhost" if the URL can't be parsed or has no host.
+func inferRPID(baseURL string) string {
+	if baseURL == "" {
+		return "localhost"
+	}
+	u, err := url.Parse(baseURL)
+	if err != nil || u.Host == "" {
+		return "localhost"
+	}
+	host := u.Hostname()
+	if host == "" {
+		return "localhost"
+	}
+	return host
+}
diff --git a/plugins/passkey/plugin_test.go b/plugins/passkey/plugin_test.go
new file mode 100644
index 0000000..77c0912
--- /dev/null
+++ b/plugins/passkey/plugin_test.go
@@ -0,0 +1,121 @@
+package passkey
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPluginInit_DerivesRPIDFromBaseURL(t *testing.T) {
+	t.Parallel()
+
+	_, plugin := newTestLimenAndPlugin(t)
+	require.NotNil(t, plugin.webauthn)
+	// The default test Limen base URL is http://localhost:8080.
+	assert.Equal(t, "localhost", plugin.config.rpID)
+	assert.NotEmpty(t, plugin.config.origins, "origins should default to the base URL")
+}
+
+func TestPluginInit_RespectsWithRPID(t *testing.T) {
+	t.Parallel()
+
+	_, plugin := newTestLimenAndPlugin(t,
+		WithRPID("example.com"),
+		WithRPName("Example"),
+		WithOrigins("https://example.com"),
+	)
+	assert.Equal(t, "example.com", plugin.config.rpID)
+	assert.Equal(t, "Example", plugin.config.rpName)
+	assert.Equal(t, []string{"https://example.com"}, plugin.config.origins)
+}
+
+func TestGenerateRegistrationOptions_RequiresSession(t *testing.T) {
+	t.Parallel()
+
+	l, _ := newTestLimenAndPlugin(t)
+
+	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/passkey/generate-register-options", http.NoBody)
+	w := httptest.NewRecorder()
+	l.Handler().ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String())
+}
+
+func TestGenerateAuthenticationOptions_PublicEndpoint(t *testing.T) {
+	t.Parallel()
+
+	l, _ := newTestLimenAndPlugin(t,
+		WithRPID("localhost"),
+		WithOrigins("http://localhost:8080"),
+	)
+
+	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/passkey/generate-authenticate-options", http.NoBody)
+	w := httptest.NewRecorder()
+	l.Handler().ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
+	// The response must include a challenge field.
+	assert.Contains(t, w.Body.String(), "challenge")
+	// A signed challenge cookie must be set so the verify endpoint
+	// can correlate the assertion to its stored state.
+	found := false
+	for _, c := range w.Result().Cookies() {
+		if c.Name == defaultChallengeCookieName {
+			found = true
+			break
+		}
+	}
+	assert.True(t, found, "challenge cookie should be set")
+}
+
+func TestVerifyAuthentication_MissingCookieReturnsChallengeNotFound(t *testing.T) {
+	t.Parallel()
+
+	l, _ := newTestLimenAndPlugin(t)
+
+	req := newJSONRequest(t, http.MethodPost, "/auth/passkey/verify-authentication", `{"response":{}}`)
+	w := httptest.NewRecorder()
+	l.Handler().ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusBadRequest, w.Code)
+	assert.Contains(t, w.Body.String(), "challenge")
+}
+
+func TestVerifyRegistration_RequiresSession(t *testing.T) {
+	t.Parallel()
+
+	l, _ := newTestLimenAndPlugin(t)
+
+	req := newJSONRequest(t, http.MethodPost, "/auth/passkey/verify-registration", `{"response":{}}`)
+	w := httptest.NewRecorder()
+	l.Handler().ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestListPasskeys_RequiresSession(t *testing.T) {
+	t.Parallel()
+
+	l, _ := newTestLimenAndPlugin(t)
+
+	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/passkey/list", http.NoBody)
+	w := httptest.NewRecorder()
+	l.Handler().ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestDeletePasskey_RequiresSession(t *testing.T) {
+	t.Parallel()
+
+	l, _ := newTestLimenAndPlugin(t)
+
+	req := newJSONRequest(t, http.MethodPost, "/auth/passkey/delete", `{"id":"1"}`)
+	w := httptest.NewRecorder()
+	l.Handler().ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
diff --git a/plugins/passkey/registration.go b/plugins/passkey/registration.go
new file mode 100644
index 0000000..942e651
--- /dev/null
+++ b/plugins/passkey/registration.go
@@ -0,0 +1,209 @@
+package passkey
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/go-webauthn/webauthn/protocol"
+	"github.com/go-webauthn/webauthn/webauthn"
+
+	"github.com/thecodearcher/limen"
+)
+
+// BeginRegistration creates the WebAuthn registration options the
+// caller's client must hand to navigator.credentials.create(). It also
+// persists the per-ceremony challenge state and sets a signed cookie
+// the corresponding FinishRegistration call will use to look it up.
+//
+// nameHint, when non-empty, is stored on the resulting PasskeyRecord
+// so users can manage credentials by a human-friendly label (e.g.
+// "MacBook Air — Touch ID").
+func (p *passkeyPlugin) BeginRegistration(ctx context.Context, r *http.Request, w http.ResponseWriter, nameHint string) (*protocol.CredentialCreation, error) {
+	session, err := requireValidatedSession(r)
+	if err != nil {
+		return nil, err
+	}
+	user := session.User
+
+	existing, err := p.listForUser(ctx, user.ID)
+	if err != nil {
+		return nil, err
+	}
+	waUser, err := p.toWebAuthnUser(user, existing)
+	if err != nil {
+		return nil, err
+	}
+
+	options, sessionData, err := p.webauthn.BeginRegistration(
+		waUser,
+		webauthn.WithExclusions(excludedDescriptors(existing)),
+	)
+	if err != nil {
+		return nil, fmt.Errorf("passkey: begin registration: %w", err)
+	}
+
+	if err := p.storeChallenge(ctx, w, &challengeState{
+		Ceremony: CeremonyRegistration,
+		UserID:   user.ID,
+		UserName: nameHint,
+		Session:  *sessionData,
+	}); err != nil {
+		return nil, err
+	}
+
+	return options, nil
+}
+
+// FinishRegistration validates the WebAuthn registration response sent
+// by the client, persists the new credential, and returns the public
+// passkey record. The challenge state is consumed regardless of
+// outcome so the same response cannot be replayed.
+func (p *passkeyPlugin) FinishRegistration(ctx context.Context, r *http.Request, body io.Reader) (*Passkey, error) {
+	session, err := requireValidatedSession(r)
+	if err != nil {
+		return nil, err
+	}
+	state, err := p.consumeChallenge(ctx, r)
+	if err != nil {
+		return nil, err
+	}
+	if state.Ceremony != CeremonyRegistration {
+		return nil, ErrChallengeMismatch
+	}
+	if !sameID(state.UserID, session.User.ID) {
+		return nil, ErrForbidden
+	}
+
+	parsed, err := protocol.ParseCredentialCreationResponseBody(body)
+	if err != nil {
+		return nil, ErrInvalidResponse
+	}
+
+	existing, err := p.listForUser(ctx, session.User.ID)
+	if err != nil {
+		return nil, err
+	}
+	waUser, err := p.toWebAuthnUser(session.User, existing)
+	if err != nil {
+		return nil, err
+	}
+
+	credential, err := p.webauthn.CreateCredential(waUser, state.Session, parsed)
+	if err != nil {
+		return nil, ErrRegistrationFailed
+	}
+
+	credIDB64 := base64.RawURLEncoding.EncodeToString(credential.ID)
+	for _, e := range existing {
+		if e.CredentialIDBase64 == credIDB64 {
+			return nil, ErrPasskeyAlreadyExists
+		}
+	}
+
+	transports := make([]string, 0, len(credential.Transport))
+	for _, t := range credential.Transport {
+		if s := strings.TrimSpace(string(t)); s != "" {
+			transports = append(transports, s)
+		}
+	}
+
+	now := time.Now()
+	rec := &PasskeyRecord{
+		UserID:             session.User.ID,
+		Name:               state.UserName,
+		CredentialIDBase64: credIDB64,
+		PublicKeyBase64:    base64.StdEncoding.EncodeToString(credential.PublicKey),
+		Counter:            int64(credential.Authenticator.SignCount),
+		DeviceType:         deviceTypeFromFlags(credential.Flags),
+		BackedUp:           credential.Flags.BackupState,
+		Transports:         strings.Join(transports, ","),
+		AAGUID:             base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
+		CreatedAt:          now,
+		UpdatedAt:          now,
+	}
+	if err := p.core.Create(ctx, p.passkeySchema, rec, nil); err != nil {
+		return nil, err
+	}
+
+	stored, err := p.findByCredentialID(ctx, credIDB64)
+	if err != nil {
+		return nil, err
+	}
+	pk := publicPasskey(stored)
+	return &pk, nil
+}
+
+// requireValidatedSession pulls the active session out of the request
+// context. The ProtectedXxx route helpers wire the session middleware
+// for us — this just returns a typed error when the middleware was not
+// applied (e.g., during direct programmatic calls).
+func requireValidatedSession(r *http.Request) (*limen.ValidatedSession, error) {
+	if r == nil {
+		return nil, ErrSessionRequired
+	}
+	session, err := limen.GetCurrentSessionFromCtx(r)
+	if err != nil || session == nil || session.User == nil {
+		return nil, ErrSessionRequired
+	}
+	return session, nil
+}
+
+func (p *passkeyPlugin) toWebAuthnUser(user *limen.User, recs []*PasskeyRecord) (*webAuthnUser, error) {
+	creds := make([]webauthn.Credential, 0, len(recs))
+	for _, rec := range recs {
+		c, err := recordToCredential(rec)
+		if err != nil {
+			return nil, err
+		}
+		creds = append(creds, c)
+	}
+	name := user.Email
+	if name == "" {
+		name = fmt.Sprintf("user-%v", user.ID)
+	}
+	return &webAuthnUser{
+		id:          userHandleFor(user.ID),
+		name:        name,
+		displayName: name,
+		credentials: creds,
+	}, nil
+}
+
+func excludedDescriptors(recs []*PasskeyRecord) []protocol.CredentialDescriptor {
+	out := make([]protocol.CredentialDescriptor, 0, len(recs))
+	for _, rec := range recs {
+		c, err := recordToCredential(rec)
+		if err != nil {
+			continue
+		}
+		out = append(out, protocol.CredentialDescriptor{
+			Type:         protocol.PublicKeyCredentialType,
+			CredentialID: c.ID,
+			Transport:    c.Transport,
+		})
+	}
+	return out
+}
+
+// sameID compares two id values typed as any. Limen adapters return
+// int64, string, or driver-specific types depending on the database,
+// so we compare on the string form.
+func sameID(a, b any) bool {
+	if a == nil || b == nil {
+		return false
+	}
+	return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
+}
+
+func deviceTypeFromFlags(f webauthn.CredentialFlags) string {
+	if f.BackupEligible {
+		return "multiDevice"
+	}
+	return "singleDevice"
+}
+
diff --git a/plugins/passkey/schema_passkey.go b/plugins/passkey/schema_passkey.go
new file mode 100644
index 0000000..0f6eb4f
--- /dev/null
+++ b/plugins/passkey/schema_passkey.go
@@ -0,0 +1,150 @@
+package passkey
+
+import (
+	"time"
+
+	"github.com/thecodearcher/limen"
+)
+
+// PasskeyRecord is the internal storage row for a single registered
+// credential. The CredentialID and PublicKey are stored base64url-encoded
+// so they can live in string columns.
+type PasskeyRecord struct {
+	ID                any
+	UserID            any
+	Name              string
+	CredentialIDBase64 string
+	PublicKeyBase64   string
+	Counter           int64
+	DeviceType        string
+	BackedUp          bool
+	Transports        string
+	AAGUID            string
+	CreatedAt         time.Time
+	UpdatedAt         time.Time
+	raw               map[string]any
+}
+
+func (p *PasskeyRecord) Raw() map[string]any { return p.raw }
+
+type passkeySchema struct {
+	limen.BaseSchema
+}
+
+func newPasskeySchema() *passkeySchema {
+	return &passkeySchema{BaseSchema: limen.BaseSchema{}}
+}
+
+func (s *passkeySchema) GetUserIDField() string {
+	return s.GetField(PasskeySchemaUserIDField)
+}
+func (s *passkeySchema) GetNameField() string {
+	return s.GetField(PasskeySchemaNameField)
+}
+func (s *passkeySchema) GetCredentialIDField() string {
+	return s.GetField(PasskeySchemaCredentialIDField)
+}
+func (s *passkeySchema) GetPublicKeyField() string {
+	return s.GetField(PasskeySchemaPublicKeyField)
+}
+func (s *passkeySchema) GetCounterField() string {
+	return s.GetField(PasskeySchemaCounterField)
+}
+func (s *passkeySchema) GetDeviceTypeField() string {
+	return s.GetField(PasskeySchemaDeviceTypeField)
+}
+func (s *passkeySchema) GetBackedUpField() string {
+	return s.GetField(PasskeySchemaBackedUpField)
+}
+func (s *passkeySchema) GetTransportsField() string {
+	return s.GetField(PasskeySchemaTransportsField)
+}
+func (s *passkeySchema) GetAAGUIDField() string {
+	return s.GetField(PasskeySchemaAAGUIDField)
+}
+func (s *passkeySchema) GetCreatedAtField() string {
+	return s.GetField(limen.SchemaCreatedAtField)
+}
+func (s *passkeySchema) GetUpdatedAtField() string {
+	return s.GetField(limen.SchemaUpdatedAtField)
+}
+
+func (s *passkeySchema) ToStorage(data limen.Model) map[string]any {
+	rec := data.(*PasskeyRecord)
+	return map[string]any{
+		s.GetUserIDField():       rec.UserID,
+		s.GetNameField():         rec.Name,
+		s.GetCredentialIDField(): rec.CredentialIDBase64,
+		s.GetPublicKeyField():    rec.PublicKeyBase64,
+		s.GetCounterField():      rec.Counter,
+		s.GetDeviceTypeField():   rec.DeviceType,
+		s.GetBackedUpField():     rec.BackedUp,
+		s.GetTransportsField():   rec.Transports,
+		s.GetAAGUIDField():       rec.AAGUID,
+	}
+}
+
+func (s *passkeySchema) FromStorage(data map[string]any) limen.Model {
+	rec := &PasskeyRecord{
+		ID:                 data[s.GetIDField()],
+		UserID:             data[s.GetUserIDField()],
+		CredentialIDBase64: stringOr(data[s.GetCredentialIDField()], ""),
+		PublicKeyBase64:    stringOr(data[s.GetPublicKeyField()], ""),
+		Counter:            int64Or(data[s.GetCounterField()], 0),
+		DeviceType:         stringOr(data[s.GetDeviceTypeField()], ""),
+		BackedUp:           boolOr(data[s.GetBackedUpField()], false),
+		Transports:         stringOr(data[s.GetTransportsField()], ""),
+		AAGUID:             stringOr(data[s.GetAAGUIDField()], ""),
+		Name:               stringOr(data[s.GetNameField()], ""),
+		raw:                data,
+	}
+	if v, ok := data[s.GetCreatedAtField()].(time.Time); ok {
+		rec.CreatedAt = v
+	}
+	if v, ok := data[s.GetUpdatedAtField()].(time.Time); ok {
+		rec.UpdatedAt = v
+	}
+	return rec
+}
+
+// stringOr / int64Or / boolOr defend against drivers that emit different
+// concrete types for the same logical column (e.g. lib/pq returning
+// []byte instead of string for TEXT, or int64 vs int for counters).
+func stringOr(v any, fallback string) string {
+	switch t := v.(type) {
+	case nil:
+		return fallback
+	case string:
+		return t
+	case []byte:
+		return string(t)
+	default:
+		return fallback
+	}
+}
+
+func int64Or(v any, fallback int64) int64 {
+	switch t := v.(type) {
+	case nil:
+		return fallback
+	case int64:
+		return t
+	case int:
+		return int64(t)
+	case int32:
+		return int64(t)
+	default:
+		return fallback
+	}
+}
+
+func boolOr(v any, fallback bool) bool {
+	switch t := v.(type) {
+	case nil:
+		return fallback
+	case bool:
+		return t
+	default:
+		return fallback
+	}
+}
diff --git a/plugins/passkey/state.go b/plugins/passkey/state.go
new file mode 100644
index 0000000..e0a1ed0
--- /dev/null
+++ b/plugins/passkey/state.go
@@ -0,0 +1,48 @@
+package passkey
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/json"
+
+	"github.com/go-webauthn/webauthn/webauthn"
+)
+
+// challengeState is what we persist in the verifications.value column
+// for each in-flight ceremony. It carries the WebAuthn library's
+// SessionData (challenge, allowed credentials, user verification
+// requirement, etc.) plus identifying information that lets the
+// "finish" endpoint look up the user without trusting the client.
+type challengeState struct {
+	Ceremony  CeremonyKind         `json:"k"`
+	UserID    any                  `json:"u,omitempty"`
+	UserName  string               `json:"un,omitempty"`
+	Session   webauthn.SessionData `json:"s"`
+}
+
+func encodeChallengeState(s *challengeState) (string, error) {
+	raw, err := json.Marshal(s)
+	if err != nil {
+		return "", err
+	}
+	return string(raw), nil
+}
+
+func decodeChallengeState(value string) (*challengeState, error) {
+	var s challengeState
+	if err := json.Unmarshal([]byte(value), &s); err != nil {
+		return nil, err
+	}
+	return &s, nil
+}
+
+// newVerificationToken returns a random URL-safe identifier used both
+// as the signed cookie value handed to the client and as the lookup
+// key for the persisted challenge row.
+func newVerificationToken() (string, error) {
+	b := make([]byte, verificationTokenByteLength)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return base64.RawURLEncoding.EncodeToString(b), nil
+}
diff --git a/plugins/passkey/state_test.go b/plugins/passkey/state_test.go
new file mode 100644
index 0000000..aed306e
--- /dev/null
+++ b/plugins/passkey/state_test.go
@@ -0,0 +1,57 @@
+package passkey
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/go-webauthn/webauthn/webauthn"
+)
+
+func TestEncodeDecodeChallengeState_RoundTrips(t *testing.T) {
+	t.Parallel()
+
+	state := &challengeState{
+		Ceremony: CeremonyRegistration,
+		UserID:   "user-123",
+		UserName: "MacBook Air",
+		Session: webauthn.SessionData{
+			Challenge:        "challenge-bytes",
+			UserID:           []byte("limen-user-user-123"),
+			UserVerification: "preferred",
+		},
+	}
+
+	encoded, err := encodeChallengeState(state)
+	require.NoError(t, err)
+	require.NotEmpty(t, encoded)
+
+	got, err := decodeChallengeState(encoded)
+	require.NoError(t, err)
+	assert.Equal(t, state.Ceremony, got.Ceremony)
+	assert.Equal(t, state.UserName, got.UserName)
+	assert.Equal(t, state.Session.Challenge, got.Session.Challenge)
+}
+
+func TestNewVerificationToken_Unique(t *testing.T) {
+	t.Parallel()
+
+	seen := map[string]bool{}
+	for i := 0; i < 100; i++ {
+		tok, err := newVerificationToken()
+		require.NoError(t, err)
+		assert.NotEmpty(t, tok)
+		assert.False(t, seen[tok], "duplicate token returned: %s", tok)
+		seen[tok] = true
+	}
+}
+
+func TestSameID_StringifiedComparison(t *testing.T) {
+	t.Parallel()
+
+	assert.True(t, sameID(int64(7), int64(7)))
+	assert.True(t, sameID(int64(7), "7"), "id comparison should be type-insensitive")
+	assert.False(t, sameID(int64(7), int64(8)))
+	assert.False(t, sameID(nil, int64(7)))
+}
diff --git a/plugins/passkey/store.go b/plugins/passkey/store.go
new file mode 100644
index 0000000..a83f988
--- /dev/null
+++ b/plugins/passkey/store.go
@@ -0,0 +1,87 @@
+package passkey
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/thecodearcher/limen"
+)
+
+// storeChallenge persists a ceremony state into the verifications table
+// and drops the lookup token into a signed cookie. The cookie is
+// HttpOnly and short-lived, and the verification row is the only
+// source of truth — we never trust client-supplied state when
+// finishing a ceremony.
+func (p *passkeyPlugin) storeChallenge(ctx context.Context, w http.ResponseWriter, state *challengeState) error {
+	token, err := newVerificationToken()
+	if err != nil {
+		return err
+	}
+	value, err := encodeChallengeState(state)
+	if err != nil {
+		return err
+	}
+	if _, err := p.dbAction.CreateVerification(ctx, passkeyChallengeAction, token, value, p.config.challengeExpiration); err != nil {
+		return err
+	}
+	maxAge := int(p.config.challengeExpiration.Seconds())
+	return p.core.Cookies().SetSignedCookie(w, p.config.challengeCookieName, token, maxAge)
+}
+
+// consumeChallenge resolves the signed-cookie token to its persisted
+// challenge state, then deletes the row so the response cannot be
+// replayed. Any error (missing cookie, expired row, decode failure)
+// surfaces as ErrChallengeNotFound to avoid leaking ceremony state.
+func (p *passkeyPlugin) consumeChallenge(ctx context.Context, r *http.Request) (*challengeState, error) {
+	token, err := p.core.Cookies().GetSignedCookie(r, p.config.challengeCookieName)
+	if err != nil || token == "" {
+		return nil, ErrChallengeNotFound
+	}
+	verification, err := p.dbAction.FindVerificationByAction(ctx, passkeyChallengeAction, token)
+	if err != nil {
+		return nil, ErrChallengeNotFound
+	}
+	// Single-use semantics: delete regardless of decode/verify outcome.
+	if err := p.dbAction.DeleteVerification(ctx, verification.ID); err != nil {
+		return nil, err
+	}
+	state, err := decodeChallengeState(verification.Value)
+	if err != nil {
+		return nil, ErrChallengeNotFound
+	}
+	return state, nil
+}
+
+func (p *passkeyPlugin) listForUser(ctx context.Context, userID any) ([]*PasskeyRecord, error) {
+	rows, err := p.core.FindMany(ctx, p.passkeySchema, []limen.Where{
+		limen.Eq(p.passkeySchema.GetUserIDField(), userID),
+	})
+	if err != nil {
+		return nil, err
+	}
+	out := make([]*PasskeyRecord, 0, len(rows))
+	for _, row := range rows {
+		out = append(out, row.(*PasskeyRecord))
+	}
+	return out, nil
+}
+
+func (p *passkeyPlugin) findByCredentialID(ctx context.Context, credentialIDB64 string) (*PasskeyRecord, error) {
+	row, err := p.core.FindOne(ctx, p.passkeySchema, []limen.Where{
+		limen.Eq(p.passkeySchema.GetCredentialIDField(), credentialIDB64),
+	}, nil)
+	if err != nil {
+		return nil, err
+	}
+	return row.(*PasskeyRecord), nil
+}
+
+func (p *passkeyPlugin) findByID(ctx context.Context, id any) (*PasskeyRecord, error) {
+	row, err := p.core.FindOne(ctx, p.passkeySchema, []limen.Where{
+		limen.Eq(p.passkeySchema.GetIDField(), id),
+	}, nil)
+	if err != nil {
+		return nil, err
+	}
+	return row.(*PasskeyRecord), nil
+}
diff --git a/plugins/passkey/testutil_test.go b/plugins/passkey/testutil_test.go
new file mode 100644
index 0000000..b054729
--- /dev/null
+++ b/plugins/passkey/testutil_test.go
@@ -0,0 +1,24 @@
+package passkey
+
+import (
+	"bytes"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/thecodearcher/limen"
+)
+
+func newTestLimenAndPlugin(t *testing.T, opts ...ConfigOption) (*limen.Limen, *passkeyPlugin) {
+	t.Helper()
+	plugin := New(opts...)
+	l, _ := limen.NewTestLimen(t, plugin)
+	return l, plugin
+}
+
+func newJSONRequest(t *testing.T, method, path, body string) *http.Request {
+	t.Helper()
+	req := httptest.NewRequestWithContext(t.Context(), method, path, bytes.NewBufferString(body))
+	req.Header.Set("Content-Type", "application/json")
+	return req
+}
diff --git a/plugins/passkey/types.go b/plugins/passkey/types.go
new file mode 100644
index 0000000..cf896c6
--- /dev/null
+++ b/plugins/passkey/types.go
@@ -0,0 +1,92 @@
+package passkey
+
+import (
+	"time"
+
+	"github.com/go-webauthn/webauthn/protocol"
+)
+
+type config struct {
+	rpID                 string
+	rpName               string
+	origins              []string
+	challengeExpiration  time.Duration
+	challengeCookieName  string
+	requireSessionToReg  bool
+	requireUserPresence  bool
+	requireUserVerified  bool
+	authenticatorSelect  *protocol.AuthenticatorSelection
+}
+
+// ConfigOption configures the passkey plugin.
+type ConfigOption func(*config)
+
+// WithRPID sets the WebAuthn Relying Party ID. Defaults to the hostname
+// parsed from the Limen BaseURL (e.g., "localhost" or "example.com").
+// The RPID must match the effective domain of every origin that uses
+// passkeys for the application.
+func WithRPID(id string) ConfigOption {
+	return func(c *config) { c.rpID = id }
+}
+
+// WithRPName sets the human-readable Relying Party name shown to the user
+// by their authenticator. Defaults to "Limen App".
+func WithRPName(name string) ConfigOption {
+	return func(c *config) { c.rpName = name }
+}
+
+// WithOrigins sets the list of allowed origins (e.g.,
+// "https://app.example.com"). If unset, the Limen BaseURL is used.
+func WithOrigins(origins ...string) ConfigOption {
+	return func(c *config) { c.origins = append([]string{}, origins...) }
+}
+
+// WithChallengeExpiration sets how long a WebAuthn challenge remains
+// valid. Defaults to 5 minutes — the browser will typically time out
+// well before this.
+func WithChallengeExpiration(d time.Duration) ConfigOption {
+	return func(c *config) { c.challengeExpiration = d }
+}
+
+// WithChallengeCookieName overrides the signed cookie name used to
+// correlate an in-flight ceremony with its server-side challenge state.
+func WithChallengeCookieName(name string) ConfigOption {
+	return func(c *config) { c.challengeCookieName = name }
+}
+
+// WithRequireSessionForRegistration controls whether a user must be
+// signed in to register a new passkey. Defaults to true. When false, the
+// caller must provide credentials via another means (e.g. a magic-link
+// signed cookie) before invoking the registration endpoints.
+func WithRequireSessionForRegistration(require bool) ConfigOption {
+	return func(c *config) { c.requireSessionToReg = require }
+}
+
+// WithRequireUserVerification requires that the authenticator perform
+// user verification (PIN, biometric, etc.) before issuing a credential
+// or assertion. Defaults to false, matching better-auth.
+func WithRequireUserVerification(require bool) ConfigOption {
+	return func(c *config) { c.requireUserVerified = require }
+}
+
+// WithAuthenticatorSelection overrides the default authenticator
+// selection criteria sent in registration options.
+func WithAuthenticatorSelection(sel *protocol.AuthenticatorSelection) ConfigOption {
+	return func(c *config) { c.authenticatorSelect = sel }
+}
+
+// Passkey is the public representation of a stored credential returned
+// by management endpoints. The PublicKey is omitted from JSON since it
+// is implementation detail that callers should not need.
+type Passkey struct {
+	ID           any       `json:"id"`
+	UserID       any       `json:"user_id"`
+	Name         string    `json:"name,omitempty"`
+	CredentialID string    `json:"credential_id"`
+	DeviceType   string    `json:"device_type"`
+	BackedUp     bool      `json:"backed_up"`
+	Transports   string    `json:"transports,omitempty"`
+	AAGUID       string    `json:"aaguid,omitempty"`
+	CreatedAt    time.Time `json:"created_at"`
+	UpdatedAt    time.Time `json:"updated_at"`
+}
diff --git a/plugins/passkey/webauthn_user.go b/plugins/passkey/webauthn_user.go
new file mode 100644
index 0000000..ce79aca
--- /dev/null
+++ b/plugins/passkey/webauthn_user.go
@@ -0,0 +1,87 @@
+package passkey
+
+import (
+	"encoding/base64"
+	"fmt"
+	"strings"
+
+	"github.com/go-webauthn/webauthn/protocol"
+	"github.com/go-webauthn/webauthn/webauthn"
+)
+
+// webAuthnUser adapts a Limen user plus their stored credentials into
+// the interface required by the go-webauthn library.
+type webAuthnUser struct {
+	id          []byte
+	name        string
+	displayName string
+	credentials []webauthn.Credential
+}
+
+func (u *webAuthnUser) WebAuthnID() []byte                         { return u.id }
+func (u *webAuthnUser) WebAuthnName() string                       { return u.name }
+func (u *webAuthnUser) WebAuthnDisplayName() string                { return u.displayName }
+func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
+
+// userHandleFor produces the WebAuthnID for a given Limen user id.
+// WebAuthn requires a non-PII handle <= 64 bytes; we use a deterministic
+// string derived from the user id so the same user always presents the
+// same handle to their authenticator.
+func userHandleFor(userID any) []byte {
+	return []byte(fmt.Sprintf("limen-user-%v", userID))
+}
+
+// recordToCredential rehydrates a stored PasskeyRecord into the shape
+// the go-webauthn library expects when verifying an authentication
+// assertion or excluding an already-registered credential from
+// registration.
+func recordToCredential(rec *PasskeyRecord) (webauthn.Credential, error) {
+	credID, err := base64.RawURLEncoding.DecodeString(rec.CredentialIDBase64)
+	if err != nil {
+		// Some clients use base64 with padding; tolerate both.
+		credID, err = base64.StdEncoding.DecodeString(rec.CredentialIDBase64)
+		if err != nil {
+			return webauthn.Credential{}, fmt.Errorf("invalid credential id encoding: %w", err)
+		}
+	}
+	pubKey, err := base64.StdEncoding.DecodeString(rec.PublicKeyBase64)
+	if err != nil {
+		pubKey, err = base64.RawURLEncoding.DecodeString(rec.PublicKeyBase64)
+		if err != nil {
+			return webauthn.Credential{}, fmt.Errorf("invalid public key encoding: %w", err)
+		}
+	}
+
+	transports := []protocol.AuthenticatorTransport{}
+	if rec.Transports != "" {
+		for _, t := range strings.Split(rec.Transports, ",") {
+			t = strings.TrimSpace(t)
+			if t != "" {
+				transports = append(transports, protocol.AuthenticatorTransport(t))
+			}
+		}
+	}
+
+	aaguid := []byte{}
+	if rec.AAGUID != "" {
+		if decoded, err := base64.StdEncoding.DecodeString(rec.AAGUID); err == nil {
+			aaguid = decoded
+		}
+	}
+
+	flags := webauthn.CredentialFlags{
+		BackupEligible: rec.BackedUp,
+		BackupState:    rec.BackedUp,
+	}
+
+	return webauthn.Credential{
+		ID:        credID,
+		PublicKey: pubKey,
+		Transport: transports,
+		Flags:     flags,
+		Authenticator: webauthn.Authenticator{
+			AAGUID:    aaguid,
+			SignCount: uint32(rec.Counter),
+		},
+	}, nil
+}