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

Passkey-first
authentication,
on the JVM.

A drop-in WebAuthn credential layer — not a hosted IdP — that mounts the same /auth/** ceremony & admin contract on Spring Boot 4, Dropwizard 5, or Micronaut 4, keeps your user table yours via one SPI, and hands back 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)
§ 001why this

Passkeys, without outsourcing your users.

Most paths to passkeys mean either months wiring WebAuthn4J by hand, or shipping your identity to a SaaS. pk-auth is the third option: a library you compile in, that does the cryptographic heavy lifting and gets out of your way.

01 · portable

One contract, three frameworks.

The same SPIs and the same /auth/** JSON run on Spring Boot 4, Dropwizard 5, and Micronaut 4 — proven by three runnable demos that share a single browser UI. Switch frameworks without rewriting your auth.

02 · yours

You keep your user table.

pk-auth owns zero PII. You implement UserLookup, returning an opaque UserHandle — the library stores only public keys and signing counters that point back at handles you mint.

03 · incremental

Boot in minutes, harden over time.

The in-memory testkit runs the whole stack with no database. Swap in JDBI + Postgres or DynamoDB when you are ready. Security defaults refuse, they do not warn.

§ 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.
  • Signed JWT out. Authentication returns a short-lived HS256 or ES256 token with configurable issuer / audience / TTL — or a stateful, revocable access token.
  • 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, no SAML, no admin console — you keep your users table.
  • 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 counters that point at handles you mint.
  • 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. Opt into strict verification if you need it.

The boundary is documented · DESIGN.md § 1

§ 003try it

One command. Real WebAuthn.

Clone the repo and run a demo. Each adapter ships the same single-page UI that exercises every flow against a real WebAuthn4J verifier — no external services, no database to provision.

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

Open http://localhost:8080 in a passkey-capable browser (Chrome, Edge, Safari, Firefox 130+). Run one at a time — all three bind to port 8080. Magic-link tokens and OTP codes print to the server console (LoggingEmailSender / LoggingSmsSender); copy them back into the form.

What the demo exercises

  • Register an account — multi-passkey enrolment, Touch ID / Windows Hello / security key.
  • Sign in and decode the issued JWT claims on-page.
  • List / rename / delete passkeys, with the last-credential guard.
  • Regenerate view-once backup codes and check the remaining count.
  • Verify email via magic link and phone via OTP.
§ 004quickstart — spring boot 4

Three files, one bean.

The minimum-viable wire-up: declare the 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. 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

Set the relying party and the JWT secret.

Three required values: the relying-party ID (your registrable domain — it scopes which passkeys can be used; example.com, not auth.example.com), the allowed origins, and the signing key (32 bytes minimum, fail-fast 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

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.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());
  }
}
Not on Spring? The Dropwizard adapter wires through a ConfiguredBundle + Dagger 2, and Micronaut through an @Factory + controllers + JWT filter — same SPIs, same wire contract. See DESIGN.md § 7 for the full per-framework wiring guide.
§ 005modules — thirteen artifacts

Compose only what you need.

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 / ES256 mint & validate 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 & 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 — intentionally not 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. 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. Native ttl honored 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 defense. 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 & SDK

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.

npm:@pk-auth/passkeys-browser TypeScript

Zero-dependency ceremony + admin clients, on npm (npm install @pk-auth/passkeys-browser) — its version tracks the server release. ESM and CJS bundles. Handles all ArrayBufferbase64url wrangling around navigator.credentials. The refresh client returns a typed result sum — it never throws on 401.

§ 006wire 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>"}
POST /auth/refresh rotates the refresh token; returns {refreshToken, accessToken, expiresAt}, or 401 {detail, reason} on replay. Mounted only with pk-auth-refresh-tokens + 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
Conventions. Bytes on the wire are base64url with no padding (RFC 4648 §5). Every non-success response carries {"outcome": "<snake_case_code>"} — e.g. origin_mismatch, challenge_expired, rate_limited (HTTP 429) — plus an optional context field such as detail or reason. The full per-variant table lives in DESIGN.md § 4.
§ 007security & maturity

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 one deliberate exception is attestation, which defaults to none — see the maturity note below.) 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.

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; 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; an in-memory Caffeine default ships. Swap for a shared store across replicas. A WAF / API gateway upstream is still recommended for heavy floods.

08 · tokens

Stateless by default, revocable when you need it

Authentication returns a short-lived JWT (one-hour default). When you need true server-side revocation — logout-everywhere, admin-revoke, user-delete before exp — the AccessTokenStore SPI is the paved road; RevocationCheck is the lighter deny-list option.

09 · sessions

Refresh-token replay defense

Rotating refresh tokens are one row per rotation with atomic mark-and-insert. A detected replay scorches the entire token family, forcing re-authentication. See ADR 0013.

10 · 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.

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 green CI / end-to-end suite — but it has not yet undergone independent third-party security review. It is production-grade in engineering rigor, but not yet security-audited — evaluate it accordingly, pin a version, and read the changelog before upgrading.

Note that attestation conveyance defaults to none (any authenticator is accepted); hosts that need attestation verification must opt into the strict WebAuthn4J manager. Read the STRIDE threat model and the stability & versioning policy.