pk-auth WebAuthn on the JVM
JDK 21 · WebAuthn4J · MIT licensed

Passkey-first
authentication,
on the JVM.

A framework-neutral WebAuthn credential layer for Spring Boot 4, Dropwizard 5, and Micronaut 4 applications. The ceremony engine is yours, the user table stays yours, and the output is a short-lived JWT — nothing leaves your servers, nothing is locked to one framework.

§ 002scope

A credential layer
— not an identity provider.

pk-auth handles the cryptographic parts of WebAuthn and hands you a JWT. It does not own users, replicate your auth model, or impose a schema on your application. The boundary is sharp, and it is documented.

+What pk-auth is

  • 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. You implement UserLookup against your existing user table; pk-auth never touches names, emails, or roles directly.
  • Stateless JWT out. Authentication returns a short-lived HS256 token with configurable issuer / audience / TTL.
  • Three persistence paths. Testkit (in-memory), JDBI + Postgres + Flyway, or DynamoDB single-table — same SPIs, your choice.

What pk-auth is not

  • An identity provider. No OIDC discovery doc, no SAML, no admin console. It is the credential part of your identity story.
  • A SaaS. It is a JAR you compile into your app. Nothing leaves your servers; no external billing or callout.
  • A user database. You bring the users table. The library stores public keys and signing counters that point at handles you mint.
  • Spring-only. The core has no Spring, Dropwizard, Micronaut, JDBC, or servlet dependency. Adapters are interchangeable.
§ 003quickstart — spring boot 4

Three files, one bean.

The minimum-viable wire-up: declare three dependencies, set three required config values, and implement UserLookup against your user table. The starter auto-configures the controllers, JWT filter, and bean defaults. The testkit fills in storage until you add a real backend.

01Dependencies

Add the adapter, persistence, and admin API.

Three Maven Central artifacts — spring-boot starter, the JDBI persistence module, and the admin endpoints. Alt-flow modules (backup codes, magic link, OTP) are independent and additive.

// Replace <version> with the latest pk-auth release.
dependencies {
  implementation("com.codeheadsystems:pk-auth-spring-boot-starter:<version>")
  implementation("com.codeheadsystems:pk-auth-persistence-jdbi:<version>")
  implementation("com.codeheadsystems:pk-auth-admin-api:<version>")
}
02Config

Set the relying party and the JWT secret.

Three required values: the relying-party ID (your eTLD+1 — example.com, not auth.example.com), the allowed origins, and the HS256 signing key (32 bytes minimum, fail-fast on boot).

pkauth:
  relying-party:
    id: example.com            # eTLD+1, 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

Bridge your existing user table.

The one Spring bean you have to write. pk-auth calls it for “does this username exist?”, “what view should I render?”, and “mint or fetch a handle for first-passkey registration”. UserHandle is an opaque byte string — store it as a BYTEA on your 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.bytes())
        .map(x -> new UserView(h, x.username(), x.displayName()));
  }

  @Override
  public UserHandle getOrCreateHandle(String u) {
    return UserHandle.of(users.findOrCreate(u).handle());
  }
}
§ 004modules — twelve artifacts

Compose only what you need.

Required Pick one Optional
§ 004.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 mint & validate via Nimbus JOSE+JWT. Ships the optional RevocationCheck SPI for “logout-all” semantics.

§ 004.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 & 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

A @Factory plus controllers and a plain @Filter JWT validator — intentionally not Micronaut Security.

§ 004.C · 3 artifacts

Persistence

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

InMemoryX for every SPI 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. Migrations run automatically. Atomic-claim via conditional UPDATE ... WHERE consumed = FALSE.

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

AWS SDK v2 Enhanced client, single physical table, schema per item type. TTL attribute honored on challenges and OTPs.

§ 004.D · 3 artifacts

Alternate flows

Optional
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 pepper-resolved at boot.

§ 004.E · 1 artifact

Admin

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

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

§ 004.F · 1 npm package

Browser SDK

Optional
npm:@pk-auth/passkeys-browser TypeScript

Zero-dependency ceremony + admin clients. ESM and CJS bundles. Handles all ArrayBufferbase64url wrangling around navigator.credentials.

§ 005wire contract

Same JSON. 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} — WebAuthn create() options
POST /auth/passkeys/registration/finish persists the credential; returns a CredentialSummary
POST /auth/passkeys/authentication/start returns {challengeId, publicKey} — WebAuthn get() options
POST /auth/passkeys/authentication/finish mints a JWT; returns {token: "<jwt>"}

Admin · bearer jwt required

GET /auth/admin/account
GET /auth/admin/credentials
PATCH /auth/admin/credentials/{id}
DELETE /auth/admin/credentials/{id} last-credential guard 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
Conventions. Bytes on the wire are base64url with no padding (RFC 4648 §5). Errors return a 4xx with {outcome, error, detail} where outcome and error carry the same machine-readable tag. The rate_limited outcome is paired with a Retry-After response header. The full per-variant table lives in DESIGN.md § 4.
§ 006security posture

Defaults that refuse, not warn.

Choices that change the security contract are explicit configuration. Defaults are the strict path: rejection on origin mismatch, rejection on counter regression, single-use challenges with a five-minute ceiling. The full STRIDE pass lives in the threat model.

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.

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 returns 409 Conflict. Backup codes remain the documented recovery path. Encourage users to enroll a second passkey before removing the first.

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 your implementation decides what crosses the boundary.

07 · flood

Built-in rate limiter

The CeremonyRateLimiter SPI throttles per-IP and per-username; in-memory Caffeine default ships. Swap for a shared store across replicas. WAF / API gateway upstream is still recommended for heavy floods.

08 · tokens

Stateless JWTs, short TTL

One-hour default TTL. No revocation list by default — the cost of statelessness. The optional RevocationCheck SPI lets hosts add logout-all or per-user disable backed by a Redis set of revoked JTIs.

09 · audit

Structured logs first

Credential deletion emits pkauth.credential.deleted. Every ceremony logs userHandle, credentialId, origin, and counter; ship to an immutable store and alert on regressions.