Passkey-first
authentication
for the JVM
A WebAuthn credential layer, not a hosted IdP, that mounts the same
/auth/** ceremony and admin contract on Spring Boot 4, Dropwizard 5, or Micronaut 4.
The host application keeps its user table behind one SPI, and each successful
authentication returns a signed JWT.
// One adapter, one persistence module, done. dependencies { implementation("com.codeheadsystems:pk-auth-spring-boot-starter:2.1.0") implementation("com.codeheadsystems:pk-auth-persistence-jdbi:2.1.0") implementation("com.codeheadsystems:pk-auth-admin-api:2.1.0") }
Passkeys without outsourced identity
Passkey support otherwise means months of wiring WebAuthn4J by hand, or moving user identity to a SaaS provider. pk-auth is a library compiled into the host application. It performs the cryptographic work and leaves user data with the host.
One contract across three frameworks
The same SPIs and the same /auth/** JSON run on Spring Boot 4, Dropwizard 5, and Micronaut 4. Three runnable demos share a single browser UI. Switching frameworks leaves the authentication code unchanged.
Host-owned user table
pk-auth owns zero PII. The host implements UserLookup, which returns an opaque UserHandle. The library stores only public keys and signing counters that point back at handles the host mints.
Incremental hardening
The whole stack boots in minutes on the in-memory testkit, with no database. JDBI + Postgres or DynamoDB replaces the testkit when the host is ready. Security defaults refuse; they do not warn.
The credential layer boundary
pk-auth handles the cryptographic parts of WebAuthn and returns a JWT. It does not own users, replicate the host's authentication model, or impose a schema on the host application.
+In scope
- A passkey credential layer. Ceremony engine, persistence SPIs, and JWT issuance, all framework-neutral inside
pk-auth-core. - Three adapters, one wire contract. Spring Boot 4, Dropwizard 5, and Micronaut 4 mount the same JSON endpoints under
/auth/**. - A set of SPIs. The host implements
UserLookupagainst its existing user table; pk-auth never touches names, emails, or roles directly. - Signed JWT out. Authentication returns a short-lived HS256 or ES256 token with configurable issuer, audience, and TTL, or a stateful, revocable access token.
- Three persistence paths. Testkit (in-memory), JDBI + Postgres + Flyway, or DynamoDB single-table. The SPIs are the same across all three, and the host selects one.
−Out of scope
- An identity provider. No OIDC discovery, no SAML, no admin console. The host keeps its users table.
- A SaaS. It is a JAR compiled into the host application. Nothing leaves the host's servers, and there is no external billing or callout.
- A user database. The host supplies the users table. The library stores public keys and counters that point at handles the host mints.
- Spring-only. The core has no Spring, Dropwizard, Micronaut, JDBC, or servlet dependency. Adapters are interchangeable.
- An attestation-policy engine. Attestation conveyance defaults to
none; there is no bundled MDS3 fetcher. Strict verification is opt-in.
Boundary reference · DESIGN.md § 1
Runnable demos with real WebAuthn
Each adapter ships the same single-page UI, which exercises every flow against a real WebAuthn4J verifier. The demos need no external services and no database. A demo runs from a clone of the repository.
git clone https://github.com/codeheadsystems/pk-auth cd pk-auth
# Tomcat + Spring Boot 4
./gradlew :examples:spring-boot-demo:run
# Jersey + Dropwizard 5
./gradlew :examples:dropwizard-demo:run
# Netty + Micronaut 4
./gradlew :examples:micronaut-demo:run
Each demo serves http://localhost:8080 to a passkey-capable browser (Chrome,
Edge, Safari, or Firefox 130+). Only one demo runs at a time, because all three bind
to port 8080. Magic-link tokens and OTP codes print to the server console
(LoggingEmailSender and LoggingSmsSender) and are copied back
into the form.
Demo coverage
- Account registration with multi-passkey enrolment, using Touch ID, Windows Hello, or a security key.
- Sign-in, with the issued JWT claims decoded on the page.
- Listing, renaming, and deleting passkeys, with the last-credential guard.
- Regenerating view-once backup codes and checking the remaining count.
- Email verification through a magic link and phone verification through an OTP.
Minimal Spring Boot wiring
The minimum wiring declares the dependencies, sets three required configuration
values, and implements UserLookup against the host's user table.
The starter auto-configures the controllers, JWT filter, and bean defaults,
and the testkit supplies storage until a real backend is added.
Library dependencies
Three Maven Central artifacts. Alt-flow modules (backup codes, magic link, OTP, refresh tokens) are independent and additive.
dependencies { implementation("com.codeheadsystems:pk-auth-spring-boot-starter:2.1.0") implementation("com.codeheadsystems:pk-auth-persistence-jdbi:2.1.0") implementation("com.codeheadsystems:pk-auth-admin-api:2.1.0") }
<dependency> <groupId>com.codeheadsystems</groupId> <artifactId>pk-auth-spring-boot-starter</artifactId> <version>2.1.0</version> </dependency> <!-- + pk-auth-persistence-jdbi, pk-auth-admin-api -->
Relying party and JWT secret
Three values are required: the relying-party ID (the registrable domain, which scopes the passkeys that can be used; example.com, not auth.example.com), the allowed origins, and the signing key (32 bytes minimum, with a fail-fast check on boot).
pkauth: relying-party: id: example.com # registrable domain, NOT auth.example.com name: My App origins: ["https://example.com"] jwt: secret: ${PKAUTH_JWT_SECRET} # ≥ 32 bytes; injected via env issuer: https://example.com audience: example.com
User table bridge
The one Spring bean the host writes. pk-auth calls it to answer “does this username exist?”, “what view should be rendered?”, and “mint or fetch a handle for first-passkey registration”. UserHandle is an opaque byte string, stored as a BYTEA on the users table with a unique index.
@Component class UserLookupBean implements UserLookup { private final UserService users; // your existing service @Override public Optional<UserHandle> findHandleByUsername(String u) { return users.findByUsername(u).map(x -> UserHandle.of(x.handle())); } @Override public Optional<UserView> findViewByHandle(UserHandle h) { return users.findByHandle(h.value()) .map(x -> new UserView( h, x.username(), x.displayName(), x.emailVerified(), x.phoneVerified())); } @Override public UserHandle getOrCreateHandle(String u) { return UserHandle.of(users.findOrCreate(u).handle()); } }
ConfiguredBundle and Dagger 2,
and the Micronaut adapter through an @Factory, controllers, and a JWT filter, with the same
SPIs and the same wire contract. DESIGN.md § 7 holds the full per-framework wiring guide.
Composable modules
Core
RequiredFramework-neutral ceremony engine, all SPIs, sealed-sum result types, and the WebAuthn4J wiring. Every other module depends on this one.
HS256 and ES256 minting and validation via Nimbus JOSE+JWT, with ES256 verification-key rotation. Ships RevocationCheck (deny-list), AccessTokenStore (stateful, server-revocable access tokens), and TokenTtlPolicy (per-audience TTLs).
Adapters
Pick oneAuto-configures controllers, JWT filter, and bean defaults for Spring Boot 4 and Spring Security 7.
A ConfiguredBundle wired via Dagger 2; mounts Jersey resources for /auth/**.
An @Factory plus controllers and a plain @Filter JWT validator. It does not use Micronaut Security.
Persistence
Pick oneIn-memory SPI implementations plus a FakeAuthenticator for driving WebAuthn ceremonies from unit tests. Always on the test classpath.
JDBI 3 + Postgres + Flyway. The host runs the shipped migrations; no adapter runs them. Atomic-claim via conditional UPDATE ... WHERE consumed = FALSE.
AWS SDK v2 Enhanced client, single physical table. Native ttl honoured on challenges, OTPs, access tokens, and refresh tokens.
Alternate flows
OptionalRotating refresh tokens with family-based replay defence. Adds POST /auth/refresh (one ceremony, one row per rotation) and needs a RefreshTokenRepository. See ADR 0013.
View-once Argon2id-hashed codes. Single-use atomic claim; per-user sliding-window rate limit.
Stateless JWT-on-the-wire magic links. Consumed-JTI tracking via a swappable ConsumedJtiStore SPI.
6-digit phone OTP with attempt caps; Argon2id-hashed storage and a deployment pepper resolved at boot.
Admin and SDK
OptionalFramework-neutral admin service: account summary, credential rename and delete (with last-credential guard), backup-code regeneration, and email and phone verification.
Zero-dependency ceremony and admin clients, published on npm (npm install @pk-auth/passkeys-browser); the version tracks the server release. ESM and CJS bundles. Handles all ArrayBuffer ↔ base64url conversion around navigator.credentials. The refresh client returns a typed result sum and does not throw on 401.
One wire contract across three adapters
Every adapter mounts the same paths under /auth/**
and consumes the same JSON shapes. The TypeScript SDK is written against this
surface; clients in other languages can target it just as directly.
Ceremony · unauthenticated
{challengeId, publicKey}, the WebAuthn create() options
CredentialSummary
{challengeId, publicKey}, the WebAuthn get() options
{token: "<jwt>"}
{refreshToken, accessToken, expiresAt}, or 401 {detail, reason} on replay. Mounted only with pk-auth-refresh-tokens and a bound RefreshTokenRepository.
Admin · bearer jwt required
409
base64url with no padding (RFC 4648 §5).
Every non-success response carries {"outcome": "<snake_case_code>"}, for example
origin_mismatch, challenge_expired, or rate_limited (HTTP 429), plus an optional
context field such as detail or reason.
DESIGN.md § 4 holds the full per-variant table.
Strict security defaults
Choices that change the security contract are explicit configuration.
Defaults take the strict path: rejection on origin mismatch, rejection on
counter regression, and single-use challenges with a five-minute ceiling.
Attestation is the exception, defaulting to none; the project maturity
note below describes it. The threat model holds the full STRIDE analysis.
Strict origin allow-list
Every finish call validates the client-reported origin against the configured allow-list. Mismatches return origin_mismatch; there is no “permit” mode.
Counter regression rejects
The authenticator's signing counter must monotonically increase. Regressions are rejected by default. Sites that primarily expect counter-0 synced passkeys can switch to warn at the cost of weakening clone detection.
Single-use challenges
Challenges are 32 random bytes, atomically consumed via ChallengeStore.takeOnce. Default TTL is five minutes; finish after expiry returns challenge_expired.
Argon2id everywhere
Backup codes and OTPs are stored as Argon2id hashes. Plaintext is returned exactly once at regeneration time. OTP additionally carries a deployment pepper resolved at boot and never logged.
Last-credential guard
Deleting the last passkey when no backup codes remain returns 409 Conflict (by default). Backup codes are the documented recovery path. Enrolling a second passkey before removing the first avoids lockout.
No PII ownership
pk-auth never stores names, emails, or display names of its own. The UserLookup SPI is the only channel to user data, and the host's implementation decides what crosses the boundary.
Built-in rate limiter
The CeremonyRateLimiter SPI throttles per-IP and per-username; an in-memory Caffeine default ships. A shared store replaces the default across replicas. A WAF or API gateway upstream is still recommended for heavy floods.
Stateless tokens with optional revocation
Authentication returns a short-lived JWT (one-hour default). True server-side revocation (logout-everywhere, admin-revoke, user-delete before exp) uses the AccessTokenStore SPI; RevocationCheck is the lighter deny-list option.
Refresh-token replay defence
Rotating refresh tokens are one row per rotation with atomic mark-and-insert. A detected replay revokes the entire token family, forcing re-authentication. See ADR 0013.
Structured logging
Credential deletion emits pkauth.credential.deleted. Every ceremony logs userHandle, credentialId, origin, and counter. Shipping these logs to an immutable store and alerting on regressions is recommended.
Project maturity
pk-auth is MIT-licensed and AI-authored under human architectural direction. It ships a full STRIDE threat model, an SPI stability policy, and a CI and end-to-end suite. It has not yet undergone independent third-party security review. Its engineering rigour is production-grade, but it is not security-audited. Pinning a version and reading the changelog before upgrading is recommended.
Attestation conveyance defaults to none, so any authenticator is accepted; hosts that
need attestation verification must opt into the strict WebAuthn4J manager.
The STRIDE threat model
and the stability and versioning policy
are the reference documents for security and compatibility.