All notable changes to pk-auth are recorded here. The format loosely follows Keep a Changelog; versions follow Semantic Versioning.
The 0.x line is treated as a single pre-stable development series — see
docs/stability.md and the early ADRs for context. This changelog starts at the
1.0.0 stabilisation cut; for 0.x history consult git log against the relevant
tags.
A security-and-correctness release driven by two rounds of adversarial review.
The headline is a set of fixes to the magic-link, OTP, and JWT alt-flows plus
server-side enforcement of per-request user verification. Passkey-core hosts
upgrade by bumping the version; hosts that implement the OtpRepository SPI
directly must add the new Instant now parameter to findLatestActive (see
Changed), and JDBI hosts pick up one new Flyway migration (V11).
- Magic-link tokens can no longer be replayed or used as bearer tokens. Links
are issued with a dedicated short TTL (15 min, owned by
MagicLinkServicerather than inherited from the 1-hour access-token TTL), the consumed-JTI retention is enforced to be ≥ the token TTL at construction, and tokens carry a dedicated audience (MagicLinkService.DEFAULT_AUDIENCE) so the resource-server validator rejects them — a magic-link token can no longer stand in for an API access token. - Magic-link login no longer delivers to attacker-supplied addresses.
MagicLinkService.startLoginsends only to the address bound to the resolved user viaUserLookup#emailFor, never the caller-supplied address (account-takeover fix). - Per-request user verification is enforced server-side. A per-ceremony
userVerification=REQUIRED(e.g. step-up) is persisted on the challenge and enforced at finish as the stricter of {global config, per-request}, so it can no longer be silently downgraded by a non-cooperative client. - JWT claim-override hardening.
JwtClaimsrejects caller-suppliedadditionalClaimsthat collide with reserved claims (iss/sub/aud/exp/jti/ pkauth.*), closing an impersonation / TTL-bypass vector. - OTP brute-force off-by-one fixed so exactly
maxAttemptsguesses are compared, and admin OTPAttemptsExceedednow surfaces as HTTP 429 rather than a 200 with the failure buried in the body. - No secret leakage:
JwtKeyset.es256puts only public JWKs inverificationSource()(a host publishing a JWKS endpoint can't leak the signing key), and theTwilioSmsSenderskeleton no longer echoes its account SID / sender number into exception messages.
Retry-After: 60on rate-limited (429) ceremony endpoints (registration/authentication start + finish), via a newheaderscomponent on the exportedCeremonyResponsewire record (back-compatible two-arg constructor); all three adapters copy it onto the HTTP response.MagicLinkService.Dependencies.ofDedicatedAudience(...)andMagicLinkService.DEFAULT_AUDIENCEfor the dedicated-audience wiring above; all three adapters wire the magic-link service through it.ChallengeRecordcarries the resolveduserVerificationrequirement (with a back-compatible four-arg constructor); JDBI persists it via Flyway migration V11 (challenges.user_verification, schema version →11).
OtpRepository.findLatestActivenow takes anInstant nowparameter (breaking for direct SPI implementers) so each backend filters expired codes against the hostClockProviderrather than the database wall clock.- Magic-link tokens are no longer recorded in the
AccessTokenStore(the dedicated magic-link issuer uses a no-op store). - Spring: the pk-auth
/auth/**security filter chain is pinned to an early@Orderso a host catch-all chain can't shadow it, and the JWT filter is no longer auto-registered as a global servlet filter (it runs only inside the chain). - Maintenance: routine Dependabot bumps (Gradle, gh-actions, npm), and the Spotless version pin / docs were reconciled (the project tracks the 8.x line).
- DynamoDB refresh-token
create()writes its primary + index items in a single atomicTransactWriteItems, so a partial write can't orphan a primary that a family revoke would later miss; a duplicaterefreshIdnow surfaces asPkAuthPersistenceException. - DynamoDB family/user revoke uses a scalar conditional
UpdateIteminstead of a read-modify-write that could clobber a concurrentusedAt; the in-memory testkit no longer does a cross-key write insideConcurrentHashMap.compute. - JDBI
updateSignCountrecordslast_used_atfor sync passkeys that always reportsignCount = 0(a strict-regression check is still enforced). AAGUIDis null-guarded inpersistRegistrationso a null AAGUID returns a sealedRegistrationResultinstead of throwing across the result boundary.
2.1.0 — 2026-06-23
A backward-compatible minor release. The headline is crypto-agility and
post-quantum readiness for passkey signature algorithms (ADR 0019): the
two divergent hardcoded COSE algorithm lists are replaced by a single
operator-configurable source of truth, and there is now a way to see which
algorithm each stored credential uses. No public API is removed and the HTTP
/auth/** wire contract is unchanged, so hosts upgrade by bumping the version.
To be precise about scope: no post-quantum signature algorithm is added — PQC for passkeys is gated end-to-end by authenticator hardware, CTAP2/FIDO2, the WebAuthn/COSE registry, and WebAuthn4J's verifier, none of which yet standardize one. The goal here is crypto-agility (clean removal of the migration obstacles) and honest documentation, not new algorithms.
- Crypto-agility for passkey signature algorithms (ADR 0019). A new
framework-neutral
CoseAlgorithmenum, andCeremonyConfignow carries two ordered lists:offeredAlgorithms(advertised in registration create-options) andacceptedAlgorithms(enforced on the WebAuthn4J verify path).acceptedAlgorithmsis authoritative andofferedAlgorithmsmust be a subset. Both the create-options ceremony and the verify path derive their lists from this single config, replacing the two previously-divergent hardcoded lists, so operators can narrow either without code changes. A new 5-argCeremonyConfigconvenience constructor applies backward-compatible defaults — accepted =ES256, EdDSA, RS256, ES384, RS384(the union of everything previously accepted, so no already-registered credential can fail verification); offered =ES256, EdDSA, RS256(the historical create-options subset) — so every existing call site compiles and behaves identically. - Per-credential algorithm visibility.
CredentialAlgorithms.coseAlgorithm(record)decodes the COSE algorithm already embedded in a stored public key (no schema change), andAdminService.listCredentialsByAlgorithm(actor, target, coseAlgorithm)reports which stored credentials use a given algorithm — the read side a future "re-enroll off algorithm X" campaign drives off. - Offered/accepted algorithm configuration wired through every adapter. The
Spring Boot starter, Dropwizard, and Micronaut adapters (and the matching demo
application.ymlfiles) expose the two algorithm lists as host config, so the create-options and verify lists can be tuned without touching code.
- Honest JWT crypto framing (ADR 0019, documentation only — no behavior
change). The HS256-vs-ES256 choice is re-documented as a trust-topology
decision rather than a dev-vs-prod one: HMAC-SHA256 is not broken by Shor and
retains ~128-bit security under Grover with the enforced ≥ 256-bit key, making
it the quantum-conservative default for a single-issuer/single-verifier
deployment; ES256 JWTs exist for untrusted third-party verification and carry
Shor exposure bounded by the short token TTL. See
docs/threat-model.mdanddocs/operator-guide.md. - Build / CI. The Playwright end-to-end suites under
examples/are now wired into each demo'scheckvia an opt-ine2eTesttask (gated onPK_RUN_E2E=1/-PrunE2e, so the default gate stays fast and Chrome-free), and CI runs all three demos in a dedicatede2ematrix job.
2.0.0 — 2026-06-13
The first 2.x release. The bulk of the work is correctness, hardening, and
maintainability; the major-version bump is driven by one breaking change to
the embedded Java API — the start* ceremony methods (described under
Changed). The HTTP /auth/** wire contract (request/response bodies and status
codes) is unchanged, so deployments that integrate through the Spring /
Dropwizard / Micronaut adapters and the JSON API need no code changes.
- Breaking (embedded API only):
start*ceremonies now return sealed result sums.PasskeyAuthenticationService.startRegistration/startAuthenticationreturnStartRegistrationResult/StartAuthenticationResult(Started | RateLimited) instead of the bare response envelope, mirroring thefinish*ceremonies, and theCeremonyRateLimitedExceptiontype is removed. A rate-limit refusal is now a value every adapter pattern-matches (compile-checked exhaustiveness) rather than an exception thrown across the boundary. Hosts using the Spring / Dropwizard / Micronaut adapters are unaffected; only code callingPasskeyAuthenticationServicedirectly must switch on the result. The200/429wire responses are identical to before. - Host→domain config translation is centralized.
RelyingPartyConfig.from(…)andCeremonyConfig.from(…)carry the shared validation and conservative default-coalescing (UVREQUIRED, counter-regressionREJECT) the three adapter factories previously duplicated; an unset knob can only resolve to the secure default. - The persistence-failure response envelope is centralized in
PkAuthPersistenceResponse(HTTP503, body{"error":"persistence_failure","operation":…}), so all three adapters emit a byte-identical body and a backend outage cannot leak a framework-default 500. - Build / CI. SonarQube Cloud was dropped for the JVM modules in favour of
native JaCoCo line + branch coverage gates (ADR 0018); it is retained for the
TypeScript browser SDK. CI now fails if
PKAUTH_SKIP_TESTCONTAINERS=1leaks into the gate, adds aworkflow_dispatchtrigger for on-demand runs, and skips the secret-dependent SonarQube / Stryker steps on Dependabot PRs.
- Passkey-only hosts can boot without the alt-flow SPIs (backup-codes / OTP / magic-link), in the admin API and Spring starter.
- Config range validation on the OTP, backup-code, and magic-link
Configrecords — non-positive TTLs, attempt caps, and rate limits are rejected at construction instead of producing never-verifiable or instantly-expired codes. - Concurrent consume-once acceptance scenarios for OTP and backup codes in
the testkit (
OtpRepositoryScenarios,BackupCodeRepositoryScenarios), run against real Postgres and DynamoDB Local — the atomic single-use guarantee is now proven for those flows, not just refresh tokens.
- DynamoDB refresh-token rotation no longer scorches a token family on a
transient error. A
TransactionCanceledExceptionis treated as a replay/race (revoking the family) only when the parent's freshness condition actually failed (ConditionalCheckFailed); throughput / transaction-conflict / validation cancellations are rethrown as a retryable 5xx, and an indeterminable reason fails closed. - Ceremony user-verification now defaults to
REQUIRED, and the UV / resident-key / attestation / counter-regression knobs are surfaced through the Spring properties. - DynamoDB maintenance scans (
deleteExpiredBefore) are constrained with a server-sidebegins_with(pk, …)filter instead of reading the whole shared single table. - OTP no-active-OTP branch drops a misleading constant-time self-compare; the throwaway HMAC that equalizes response timing is kept.
- Admin phone-verification maps the
SendResultsum with an exhaustive switch instead of an unchecked downcast. - Hardened CBOR / converter robustness and added end-to-end forged / replayed ceremony coverage.
- esbuild forced to ≥ 0.28.1 to clear GHSA-gv7w-rqvm-qjhr (HIGH). It is a build-time, dev-only transitive dependency of the browser SDK (never shipped in the published bundle), and the advisory targets esbuild's Deno module, so the practical exposure was low — but the bump clears the advisory at the source.
- Browser SDK base64url no longer uses a ReDoS-shaped regex (SonarQube
S5852 / CWE-1333);
encode/decodeusereplaceAllwith string literals, byte-identical output. - Documentation corrections to security-relevant behaviour. OTP codes are
hashed with HMAC-SHA256 + a server-side pepper (not Argon2id); the
userVerification = REQUIREDdefault and the ≥ 16-byte OTP pepper requirement are now documented in the threat model and operator guide.
1.3.1 — 2026-06-06
Patch release: a browser-SDK build fix, a Micronaut admin toggle, and supply-chain hardening. No wire-format or API-breaking changes.
- Micronaut: the admin endpoints can now be disabled. A new
configuration toggle lets a Micronaut host opt out of mounting
PkAuthAdminController(passkey list/rename/delete, etc.) while keeping the core auth endpoints, mirroring the disable switch the other framework integrations already expose. Seedocs/operator-guide.md.
- Browser SDK builds under TypeScript 6.0. The dev-dependency bump to
TypeScript 6.0 broke the
tsup.d.tspipeline withTS5101(the injectedbaseUrlis now a deprecation error). AddingignoreDeprecations: "6.0"to the SDKtsconfig.jsonrestores the build; the ESM/CJS/DTS bundle,tsc --noEmit, and all vitest tests pass under TS 6.0.
- Dependency and CI/CD supply-chain hardening.
- Every GitHub Actions
uses:is pinned to a full commit SHA (version in a trailing comment) instead of a mutable@vNtag — most importantly the third-partysoftprops/action-gh-releasein the privileged release job. - The Gradle distribution is pinned via
distributionSha256Sumin the wrapper, with awrapper-validationstep in CI verifyinggradle-wrapper.jar. actions/dependency-review-actionruns as a PR gate (fail-on-severity: high) to block newly introduced dependencies with known high-severity advisories.- Dependabot now covers the npm ecosystem (the published browser SDK and each demo's Playwright e2e suite) and gates auto-merge on update type, so only patch/minor bumps are auto-approved and Actions updates never auto-merge.
- The build/distribution trust boundary and these mitigations are documented.
- Every GitHub Actions
- Gradle dependency verification (
verification-metadata.xml) was not retained. It was introduced during the supply-chain work and then removed: with Dependabot auto-merging Gradle bumps, the checksum file would have to be regenerated unattended from whatever was just downloaded (notarizing rather than vetting), and it broke the build on every bump because a version-catalog bump cannot update the checksums. Protection against malicious dependency releases is provided bydependency-review-action's advisory database instead. The SHA-pinned Actions, pinned Gradle distribution, and dependency-review gate above are retained.
- Routine Dependabot bumps across the dev/runtime dependency groups (Gradle and npm) and GitHub Actions.
1.3.0 — 2026-06-03
Security-review follow-ups (hardening; no known exploit in the items below).
- Refresh tokens carry the original
amrthrough rotation.RefreshTokenService.issue(UserHandle, String, Optional, List<String>)records the RFC 8176 authentication method references on the refresh family, and every rotation propagates them verbatim, so an access token minted fromPOST /auth/refreshreflects how the session was first established (e.g.["pkauth","webauthn"]) instead of a generic["user"].RefreshTokenRecordandRotatedClaimsgain anamrfield; persisted as theamrcolumn (JDBI, Flyway V10) and theamrattribute (DynamoDB). The previous three-argissue(UserHandle, String, Optional)is deprecated (defaultsamrto["user"], preserving prior behavior). ChallengeStoreScenariosinpk-auth-testkit: a shared SPI compliance suite assertingChallengeStore.takeOnceis single-use, including a concurrent "exactly one winner" race test, now driven against the in-memory, JDBI/Postgres, and DynamoDB backends.
ChallengeIdis now an opaque random handle, decoupled from the challenge bytes. Previously the store key wasbase64url(challenge); it is now a random UUID (ChallengeId.random()). Finish-time validation still enforces the cryptographic binding by byte-comparing the stored challenge against the bytes the authenticator signed (and WebAuthn4J re-checks the same challenge), so the store key no longer reveals or depends on the challenge. No wire-format change; the browser SDK already round-trips the id.ChallengeGenerator.idOf(byte[])and the internalChallengeValidation.IdMismatchvariant are removed.PkAuthJwtValidator(stateful mode) rejectsjti-less tokens. When a non-noopAccessTokenStoreis bound, a validly-signed token with nojtinow returnsMissingClaim("jti")instead of bypassing the revocation gate. (Carried over from 1.2.0's fix; the validator now detects stateful mode up front.)- DynamoDB refresh rotation uses a conditional
UpdateItem.rotateAtomicallynow marks the parent used via a partial conditional update (ignoreNullsMode(SCALAR_ONLY)) instead of a full-item PUT from a prior read — it no longer reads the parent first and can't clobber a concurrently-written parent attribute. Behavior is unchanged; the freshness condition (incl. the numeric-ttlexpiry compare from 1.2.0) is preserved.
- Documented that the stateless access-token TTL is the effective
revocation window (
JwtConfig), thatAccessTokenStore.existsmust fail closed on outage, that customRevocationCheckdeny-lists must handle anulljti, and that the Spring JWT filter is additive (never clears a pre-existingSecurityContext).
- The browser SDK is now published to npm as
@pk-auth/passkeys-browserat1.3.0— its first npm release. The SDK version tracks the pk-auth server release it speaks to, so1.3.0matches this release's Maven Central artifacts. Install withnpm install @pk-auth/passkeys-browser. SeeRELEASE.mdfor the publish steps. (The example apps still consume the SDK via a relativedist/import built by Gradle, not the published package.)
1.2.0 — 2026-06-02
- DynamoDB refresh-token rotation now compares expiry numerically, not
lexically.
DynamoDbRefreshTokenRepository.rotateAtomicallygated the atomic-rotateConditionExpressiononexpiresAtIso > :now, comparing twoInstant.toString()strings. That output is variable-precision (the fractional-seconds field is dropped when zero), so a bytewise>sorts…:00Zafter…:00.000001Zand could treat a just-expired token as still fresh. The condition now compares the numeric epoch-secondttlattribute (#ttl > :nowEpoch). The authoritative expiry check inRefreshTokenServicewas already correct, so the exposure was limited to a sub-second fail-open in the database-level backstop for the rotate TOCTOU window. - Stateful JWT validation rejects
jti-less tokens instead of skipping revocation. When a non-noopAccessTokenStoreis bound,PkAuthJwtValidatorpreviously guarded the store lookup withjti != null, so a validly-signed, in-issuer, in-audience, unexpired token carrying nojtibypassed theexistsrevocation gate entirely. The validator now returnsMissingClaim("jti")for ajti-less token in stateful mode. Tokens minted byPkAuthJwtIssueralways carry ajti, so this never affected the library's own tokens; it closes the gap for any other token signed with the same keyset. - Corrected the
MagicLinkService.startLogintiming-side-channel contract (documentation). The javadoc claimed the method defeats timing side-channels, but the not-found path returns before JWT issuance and email dispatch, so response latency still distinguishes known from unknown usernames. The contract now documents the result-shape guarantee only and points hosts at uniform-latency / rate-limiting mitigations. No behavioural change.
1.1.0 — 2026-05-18
TokenTtlPolicySPI inpk-auth-jwtfor per-audience JWT access-token TTL dispatch. Multi-client deployments (e.g. web vs cli vs mobile) can now configure different token lifetimes per audience without writing a custom issuer. Static factoriesTokenTtlPolicy.fixed(default, overrides)andTokenTtlPolicy.single(ttl)cover the common cases; implementations can also declare theirknownAudiences()so the validator accepts the expanded audience set automatically. See ADR 0014.JwtClaimsgains an optionalaudiencefield. When the issuer is called with a claims object whoseaudienceis null, the JWT'saudclaim falls back toJwtConfig.defaultAudience(). New convenience factoryJwtClaims.forPasskey(userHandle, credentialId, audience, amr)etc. mirror the existing audience-less factories.AccessTokenStoreSPI inpk-auth-jwtfor stateful (server-revocable) access tokens.PkAuthJwtIssuercallsrecordon every issue;PkAuthJwtValidatorcallsexistson every validate. The defaultAccessTokenStore.noop()keeps stateless behaviour; hosts wire a real store (JDBI, DynamoDB) to opt in. See ADR 0015.pk-auth-refresh-tokens(new module):RefreshTokenServicewithissue,rotate,revokeFamily,revokeAllForUser,listForUser. SealedRotateResult(Success / Replayed / Expired / Unknown / Revoked). Wire format{refreshId}.{secret}(both halves base64url); SHA-256 hash-at-rest; hash-before-mark-used invariant. Family-based replay defense — re-presenting a used token scorches the entire family. See ADR 0013.RefreshTokenRepositorySPI with the load-bearingrotateAtomicallyprimitive: parent mark-used and successor insert commit atomically on every backend (JDBI transaction, DynamoDBTransactWriteItems, in-memoryConcurrentHashMap.compute).pk-auth-persistence-jdbi:JdbiRefreshTokenRepository+ Flyway V9 migration. PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION → "9".pk-auth-persistence-dynamodb:DynamoDbRefreshTokenRepositorywith three-item layout (primary jti / user-index / family-index) and DynamoDB-native TTL.pk-auth-testkit:InMemoryRefreshTokenRepository+RefreshTokenScenariosparity test class — nine scenarios including the non-negotiableconcurrentRotationExactlyOneSucceedsFamilyRevokedrace test, driven by 8 threads +CountDownLatch, passing against in-memory, real Postgres, and DynamoDB Local.RefreshHandler(framework-neutral HTTP composer) +PkAuthRefreshController/PkAuthRefreshResourcein Spring, Dropwizard, Micronaut adapters.POST /auth/refreshreturns the new refresh + access JWT on success, 401 with a typeddetailon any failure.AuthMethod.REFRESHfor access tokens minted from a refresh rotation.JwtClaims.forRefresh(userHandle, audience, amr)factory.- Browser SDK:
PkAuthClient.refresh(wireToken)returning a typedRefreshResultsum (RefreshSuccess | RefreshFailure) — never throws on 401. RefreshTokenServiceDeletionListenerplugged intoUserDeletionServiceso user-delete revokes every active refresh family alongside access tokens / credentials / backup codes / OTPs.pk-auth-persistence-jdbi:JdbiAccessTokenStorebacked by the newaccess_tokenstable (Flyway V8).pk-auth-persistence-dynamodb:DynamoDbAccessTokenStoreusing two items per JTI (primary jti-keyed item + user-indexed pointer item) with DynamoDB native TTL on thettlattribute for asynchronous expiry cleanup.pk-auth-testkit:InMemoryAccessTokenStore+AccessTokenStoreScenariosparity-test class driven from in-memory / JDBI / DynamoDB integration tests.UserDeletionServiceandUserDeletionListenerSPI inpk-auth-core(com.codeheadsystems.pkauth.lifecycle). Single fan-out point that runs every registered listener for a user, with idempotent + best-effort semantics and structuredpkauth.user.deletionlogging. See ADR 0016. The library ships listeners for credentials, backup codes, OTPs, and access tokens; each adapter wires them automatically.CredentialRepository.deleteByUserHandle(UserHandle)andOtpRepository.deleteByUserHandle(UserHandle)SPI methods for the fan-out. Implemented in all three persistence variants.
- Breaking.
JwtConfig.tokenTtl: Durationreplaced byJwtConfig.ttlPolicy: TokenTtlPolicy. Pre-1.1 code that constructedJwtConfigdirectly must wrap the existing TTL inTokenTtlPolicy.single(ttl). TheJwtConfig.defaults(issuer, audience)factory still works as before and produces a single-TTL policy. - Breaking.
JwtConfig.audiencerenamed toJwtConfig.defaultAudience. The accessor moves fromconfig.audience()toconfig.defaultAudience(). Semantic note: the validator now accepts any audience listed indefaultAudience ∪ ttlPolicy.knownAudiences(), which matters when running multiple client audiences through a single validator. - Breaking (adapters).
PkAuthProperties.Jwt(Spring),PkAuthConfig.Jwt(Dropwizard), andPkAuthConfiguration.Jwt(Micronaut) each gain attlsByAudience: Map<String, Duration>field and rename their single-TTL field; see each adapter's javadoc for the bound property name. - Breaking (SPI).
CredentialRepositoryandOtpRepositorygain adeleteByUserHandle(UserHandle) -> intmethod. All shipped implementations (in-memory, JDBI, DynamoDB) updated. Downstream host-supplied implementations must add it; the natural impl is a single bulk-delete statement keyed on the user-handle column. PkAuthJwtIssuerandPkAuthJwtValidatorgain new constructors that accept anAccessTokenStore. The legacy three-arg constructors remain and default toAccessTokenStore.noop().- Flyway schema version bumped to V9 (V8 access tokens + V9 refresh tokens).
PkAuthJdbiSchema.CURRENT_SCHEMA_VERSIONis now"9".
1.0.0 — 2026-05 (stabilisation cut)
First stable release. Captures the surface produced by the 0.x development
series; see git log for the full history.