pk-auth WebAuthn on the JVM
v2.1.0 JDK 21 MIT WebAuthn4J

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.

Spring Boot 4 Dropwizard 5 Micronaut 4
// 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")
}
WebAuthn assertion in → signed JWT out (HS256 / ES256)
§ 001rationale

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.

01 · portable

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.

02 · yours

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.

03 · incremental

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.

§ 002scope

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 UserLookup against 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

§ 003demo

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.
§ 004quickstart: spring boot 4

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.

01Dependencies

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 -->
02Config

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
03UserLookup

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());
  }
}
The Dropwizard adapter wires through a 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.
§ 005modules: thirteen artifacts

Composable modules

Required Pick one Optional
§ 005.A · 2 artifacts

Core

Required
com.codeheadsystems:pk-auth-core Always

Framework-neutral ceremony engine, all SPIs, sealed-sum result types, and the WebAuthn4J wiring. Every other module depends on this one.

com.codeheadsystems:pk-auth-jwt Always

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).

§ 005.B · 3 artifacts

Adapters

Pick one
com.codeheadsystems:pk-auth-spring-boot-starter Spring 4

Auto-configures controllers, JWT filter, and bean defaults for Spring Boot 4 and Spring Security 7.

com.codeheadsystems:pk-auth-dropwizard Dropwizard 5

A ConfiguredBundle wired via Dagger 2; mounts Jersey resources for /auth/**.

com.codeheadsystems:pk-auth-micronaut Micronaut 4

An @Factory plus controllers and a plain @Filter JWT validator. It does not use Micronaut Security.

§ 005.C · 3 artifacts

Persistence

Pick one
com.codeheadsystems:pk-auth-testkit In-memory

In-memory SPI implementations plus a FakeAuthenticator for driving WebAuthn ceremonies from unit tests. Always on the test classpath.

com.codeheadsystems:pk-auth-persistence-jdbi Postgres

JDBI 3 + Postgres + Flyway. The host runs the shipped migrations; no adapter runs them. Atomic-claim via conditional UPDATE ... WHERE consumed = FALSE.

com.codeheadsystems:pk-auth-persistence-dynamodb DynamoDB

AWS SDK v2 Enhanced client, single physical table. Native ttl honoured on challenges, OTPs, access tokens, and refresh tokens.

§ 005.D · 4 artifacts

Alternate flows

Optional
com.codeheadsystems:pk-auth-refresh-tokens Sessions

Rotating refresh tokens with family-based replay defence. Adds POST /auth/refresh (one ceremony, one row per rotation) and needs a RefreshTokenRepository. See ADR 0013.

com.codeheadsystems:pk-auth-backup-codes Recovery

View-once Argon2id-hashed codes. Single-use atomic claim; per-user sliding-window rate limit.

com.codeheadsystems:pk-auth-magic-link Email

Stateless JWT-on-the-wire magic links. Consumed-JTI tracking via a swappable ConsumedJtiStore SPI.

com.codeheadsystems:pk-auth-otp SMS

6-digit phone OTP with attempt caps; Argon2id-hashed storage and a deployment pepper resolved at boot.

§ 005.E · 1 artifact + 1 npm

Admin and SDK

Optional
com.codeheadsystems:pk-auth-admin-api /auth/admin/**

Framework-neutral admin service: account summary, credential rename and delete (with last-credential guard), backup-code regeneration, and email and phone verification.

npm:@pk-auth/passkeys-browser TypeScript

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.

§ 006wire contract

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

POST /auth/passkeys/registration/start returns {challengeId, publicKey}, the WebAuthn create() options
POST /auth/passkeys/registration/finish persists the credential; returns a CredentialSummary
POST /auth/passkeys/authentication/start returns {challengeId, publicKey}, the WebAuthn get() options
POST /auth/passkeys/authentication/finish mints a JWT; returns {token: "<jwt>"}
POST /auth/refresh rotates the refresh token; returns {refreshToken, accessToken, expiresAt}, or 401 {detail, reason} on replay. Mounted only with pk-auth-refresh-tokens and a bound RefreshTokenRepository.

Admin · bearer jwt required

GET/auth/admin/account
GET/auth/admin/credentials
PATCH/auth/admin/credentials/{id}
DELETE /auth/admin/credentials/{id} deleting the last passkey with no backup codes left returns 409
POST/auth/admin/backup-codes/regenerate
GET/auth/admin/backup-codes/count
POST/auth/admin/email/{start,complete}-verification
POST/auth/admin/phone/{start,complete}-verification
Bytes on the wire are 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.
§ 007security and maturity

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.

01 · origin

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.

Threat model ↗
02 · clones

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.

03 · replay

Single-use challenges

Challenges are 32 random bytes, atomically consumed via ChallengeStore.takeOnce. Default TTL is five minutes; finish after expiry returns challenge_expired.

04 · hashing

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.

05 · lockout

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.

06 · pii

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.

07 · flood

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.

08 · tokens

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.

09 · sessions

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.

10 · audit

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.