A small, cross-platform (JVM + Scala Native) WebAuthn assertion verifier for Scala 3 — the server side of a passkey login.
Given the fields a browser returns from navigator.credentials.get() and the expected
relying-party context, it tells you whether the assertion is valid. It's pure and total:
inputs in, Either[String, Unit] out, never throws. The only platform-specific piece is the
ES256 signature check, which uses JCA on the JVM and OpenSSL (via FFI) on Native.
It also ships a stateless HMAC challenge helper so you don't need a server-side challenge store.
- ✅ Assertion (authentication) verification: origin, rpId hash, user-present flag, challenge match, ES256 signature.
- ✅ Stateless challenge issue/validate (HMAC-SHA256, TTL-bounded, no storage).
- ❌ Attestation (registration) verification — out of scope. Registration is expected to happen out-of-band: capture the credential's public key (SPKI) once and hand it to this library as an expectation.
libraryDependencies += "org.polyvariant" %%% "webauthn4s" % "<version>"import org.polyvariant.webauthn4s.AssertionVerifier
import org.polyvariant.webauthn4s.AssertionVerifier.{Assertion, Expectations}
import scodec.bits.ByteVector
val expected = Expectations(
rpId = "example.com",
origin = "https://example.com",
publicKeySpki = ByteVector(/* the credential's DER SPKI public key */),
)
val assertion = Assertion(
authenticatorData = /* bytes from the browser */,
clientDataJson = /* bytes from the browser */,
signature = /* bytes from the browser */,
challenge = /* the raw challenge bytes you issued */,
)
AssertionVerifier.verify(expected, assertion) match {
case Right(()) => // authenticated
case Left(reason) => // reject; `reason` names the first failing check
}Stateless challenges. The library is F[_]-polymorphic: you build a Challenge[F] over a
fixed secret and TTL (with an ambient SecureRandom[F]), then pass it around. You supply the
current time (as a FiniteDuration since the epoch) per call, so it stays referentially
transparent.
import cats.effect.IO
import cats.effect.std.SecureRandom
import org.polyvariant.webauthn4s.Challenge
import scala.concurrent.duration.*
for {
given SecureRandom[IO] <- SecureRandom.javaSecuritySecureRandom[IO]
challenge = Challenge[IO](secret, ttl = 60.seconds) // hold & pass this around
now <- IO.realTime // FiniteDuration since epoch
token <- challenge.issue(now) // hand `token` to the client
// ...later, on verify:
raw <- challenge.validate(token, now) // Either[String, ByteVector]
} yield rawThe Native Es256Platform binds OpenSSL's libcrypto. Consuming Native builds must link it
into the final binary, e.g. in your project.scala / build:
//> using nativeLinking -lcrypto
libcrypto must be available at link time (it usually already is via OpenSSL).
scodec, jsoniter-scala-core, fs2 (pure hashing — no FFI), and cats-effect-std (for the
Hashing/MonadCancelThrow/SecureRandom typeclasses — the library itself is
F[_]-polymorphic). No CBOR or COSE parsing (assertions don't need it).
Apache 2.0.