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.
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
UserLookupagainst 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.
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.
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>") }
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
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()); } }
Compose only what you need.
Core
RequiredFramework-neutral ceremony engine, all SPIs, sealed-sum result types, and the WebAuthn4J wiring. Every other module depends on this one.
HS256 mint & validate via Nimbus JOSE+JWT. Ships the optional RevocationCheck SPI for “logout-all” semantics.
Adapters
Pick oneAuto-configures controllers, JWT filter, and bean defaults for Spring Boot 4 & Spring Security 7.
A ConfiguredBundle wired via Dagger 2; mounts Jersey resources for /auth/**.
A @Factory plus controllers and a plain @Filter JWT validator — intentionally not Micronaut Security.
Persistence
Pick oneInMemoryX for every SPI plus a FakeAuthenticator for driving WebAuthn ceremonies from unit tests. Always on the test classpath.
JDBI 3 + Postgres + Flyway. Migrations run automatically. Atomic-claim via conditional UPDATE ... WHERE consumed = FALSE.
AWS SDK v2 Enhanced client, single physical table, schema per item type. TTL attribute honored on challenges and OTPs.
Alternate flows
OptionalView-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 pepper-resolved at boot.
Admin
OptionalFramework-neutral admin service: account summary, credential rename / delete (with last-credential guard), backup-code regeneration, email & phone verification.
Browser SDK
OptionalZero-dependency ceremony + admin clients. ESM and CJS bundles. Handles all ArrayBuffer ↔ base64url wrangling around navigator.credentials.
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
{challengeId, publicKey} — WebAuthn create() options
CredentialSummary
{challengeId, publicKey} — WebAuthn get() options
{token: "<jwt>"}
Admin · bearer jwt required
409
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.
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.
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 returns 409 Conflict. Backup codes remain the documented recovery path. Encourage users to enroll a second passkey before removing the first.
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.
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.
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.
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.