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.
// 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 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.
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.
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.
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.
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. - 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
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.
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.
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 -->
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
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()); } }
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.
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 / 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).
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/**.
An @Factory plus controllers and a plain @Filter JWT validator — intentionally not 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. Migrations run automatically. Atomic-claim via conditional UPDATE ... WHERE consumed = FALSE.
AWS SDK v2 Enhanced client, single physical table. Native ttl honored on challenges, OTPs, access tokens, and refresh tokens.
Alternate flows
OptionalRotating refresh tokens with family-based replay defense. 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 & SDK
OptionalFramework-neutral admin service: account summary, credential rename / delete (with last-credential guard), backup-code regeneration, email & phone verification.
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 ArrayBuffer ↔ base64url wrangling around navigator.credentials. The refresh client returns a typed result sum — it never throws on 401.
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>"}
{refreshToken, accessToken, expiresAt}, or 401 {detail, reason} on replay. Mounted only with pk-auth-refresh-tokens + a bound RefreshTokenRepository.
Admin · bearer jwt required
409
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.
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.
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; 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; an in-memory Caffeine default ships. Swap for a shared store across replicas. A WAF / API gateway upstream is still recommended for heavy floods.
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.
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.
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.