A Convex component for passwordless WebAuthn passkey authentication with self-minted JWTs, multi-device support, session management, and React hooks.
- Passkey registration - Register new passkeys and associate them with user identifiers
- Challenge/response authentication - Full WebAuthn challenge/response flow via browser APIs
- Self-minted JWTs - HMAC-SHA256 signed session tokens compatible with Convex auth
- Session management - Configurable expiry and automatic refresh strategy
- Multi-device support - Multiple passkeys per user (phone, laptop, security key)
getOrCreateUserhelper - Stable user identifier from passkey ID- Preview deployment support - No hardcoded origins; rpId derived from client
- React hooks -
usePasskeyRegister,usePasskeyLogin,usePasskeyAuth - Server-side validation - Validate sessions and get current user from any Convex function
- Session invalidation - Logout (single session) and logout-all (force everywhere)
- Passkey revocation - Revoke individual passkeys when devices are lost
npm install convex-passkey-auth// convex/convex.config.ts
import { defineApp } from "convex/server";
import passkeyAuth from "convex-passkey-auth/convex.config";
const app = defineApp();
app.use(passkeyAuth);
export default app;// convex/auth.ts
import { PasskeyAuth } from "convex-passkey-auth";
import { components } from "./_generated/api";
export const passkeyAuth = new PasskeyAuth(components.passkeyAuth, {
rpName: "My App",
sessionExpiryMs: 30 * 24 * 60 * 60 * 1000, // 30 days
refreshAfterMs: 24 * 60 * 60 * 1000, // refresh daily
});// convex/passkeys.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { passkeyAuth } from "./auth";
export const generateRegistrationChallenge = mutation({
args: { identifier: v.string(), displayName: v.optional(v.string()) },
handler: async (ctx, args) => {
return await passkeyAuth.generateRegistrationOptions(ctx, args);
},
});
export const verifyRegistration = mutation({
args: {
identifier: v.string(),
credentialId: v.string(),
publicKey: v.string(),
challenge: v.string(),
counter: v.number(),
deviceName: v.optional(v.string()),
displayName: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await passkeyAuth.verifyRegistration(ctx, args);
},
});
export const generateAuthChallenge = mutation({
args: { identifier: v.optional(v.string()) },
handler: async (ctx, args) => {
return await passkeyAuth.generateAuthenticationOptions(ctx, args);
},
});
export const verifyAuth = mutation({
args: {
credentialId: v.string(),
challenge: v.string(),
counter: v.number(),
},
handler: async (ctx, args) => {
return await passkeyAuth.verifyAuthentication(ctx, args);
},
});
export const validateSession = mutation({
args: { tokenHash: v.string() },
handler: async (ctx, args) => {
return await passkeyAuth.validateSession(ctx, args.tokenHash);
},
});
export const logout = mutation({
args: { tokenHash: v.string() },
handler: async (ctx, args) => {
return await passkeyAuth.logout(ctx, args.tokenHash);
},
});// src/App.tsx
import { usePasskeyRegister, usePasskeyLogin, usePasskeyAuth } from "convex-passkey-auth/react";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function App() {
const generateRegChallenge = useMutation(api.passkeys.generateRegistrationChallenge);
const verifyReg = useMutation(api.passkeys.verifyRegistration);
const generateAuthChallenge = useMutation(api.passkeys.generateAuthChallenge);
const verifyAuth = useMutation(api.passkeys.verifyAuth);
const validateSessionMutation = useMutation(api.passkeys.validateSession);
const logoutMutation = useMutation(api.passkeys.logout);
const { register, isRegistering } = usePasskeyRegister({
generateChallenge: generateRegChallenge,
verifyRegistration: verifyReg,
rpName: "My App",
});
const { login, isLoggingIn } = usePasskeyLogin({
generateChallenge: generateAuthChallenge,
verifyAuthentication: verifyAuth,
});
const { user, isAuthenticated, isLoading, logout } = usePasskeyAuth({
validateSession: validateSessionMutation,
invalidateSession: logoutMutation,
});
if (isLoading) return <div>Loading...</div>;
if (isAuthenticated) {
return (
<div>
<p>Welcome, {user?.userId}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
return (
<div>
<button
onClick={() => register("user@example.com", "John Doe")}
disabled={isRegistering}
>
{isRegistering ? "Registering..." : "Register Passkey"}
</button>
<button
onClick={() => login("user@example.com")}
disabled={isLoggingIn}
>
{isLoggingIn ? "Logging in..." : "Login with Passkey"}
</button>
</div>
);
}| Method | Description |
|---|---|
generateRegistrationOptions(ctx, { identifier, displayName? }) |
Generate WebAuthn registration challenge |
verifyRegistration(ctx, { identifier, credentialId, publicKey, challenge, counter }) |
Verify registration and store passkey |
generateAuthenticationOptions(ctx, { identifier? }) |
Generate WebAuthn authentication challenge |
verifyAuthentication(ctx, { credentialId, challenge, counter }) |
Verify authentication and create session |
validateSession(ctx, tokenHash) |
Validate a session token |
logout(ctx, tokenHash) |
Invalidate a single session |
logoutAll(ctx, userId) |
Invalidate all sessions for a user |
getOrCreateUser(ctx, { identifier, displayName? }) |
Find or create user by identifier |
getUser(ctx, userId) |
Get user info |
listPasskeys(ctx, userId) |
List passkeys for a user |
revokePasskey(ctx, credentialId) |
Revoke a specific passkey |
cleanupExpiredSessions(ctx) |
Delete expired sessions (for cron) |
cleanupExpiredChallenges(ctx) |
Delete expired challenges (for cron) |
| Hook | Returns |
|---|---|
usePasskeyRegister(options) |
{ register, isRegistering, error } |
usePasskeyLogin(options) |
{ login, isLoggingIn, error } |
usePasskeyAuth(options) |
{ user, isAuthenticated, isLoading, logout } |
| Function | Description |
|---|---|
hashSessionToken(token) |
Hash a raw session token for server-side validation |
Set up cleanup crons to keep the database tidy:
// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.daily("cleanup sessions", { hourUTC: 3, minuteUTC: 0 }, internal.cleanup.expiredSessions);
crons.hourly("cleanup challenges", { minuteUTC: 30 }, internal.cleanup.expiredChallenges);
export default crons;MIT