BoxLang 🚀 A New JVM Dynamic Language Learn More...

BxPasskeys

v0.2.0 BoxLang Modules

bx-passkeys

WebAuthn passkeys for BoxLang: let people register a passkey and sign in with it, no password. It uses only JDK crypto (java.security), with no third-party jars.

  • Attestation "none": it trusts the key the authenticator hands over and doesn't check where the authenticator came from. That's what nearly every site wants.
  • ES256 (P-256), Ed25519 and RS256: the three algorithms browsers actually use.
  • It reads the new key from the browser's getPublicKey() (SPKI) or, when that's missing, from the COSE key inside attestationObject. A small CBOR decoder is built in.
  • Each challenge is kept in a session struct that you pass in. It works once and expires after challengeSeconds.
  • Passkeys live in a credential store you choose. A table-backed store and an in-memory store come with the module.
  • Framework-agnostic: there are no routes. You call four methods from your own handlers.

Requires BoxLang 1.19+.

Install

install-bx-module bx-passkeys        # into BoxLang HOME
install-bx-module bx-passkeys --local  # into ./boxlang_modules
box install bx-passkeys              # with CommandBox

To try a local checkout before it's on ForgeBox (install-bx-module only installs from ForgeBox), copy the module's files into your project's boxlang_modules:

mkdir -p boxlang_modules/bx-passkeys && cp -R /path/to/bx-passkeys/{ModuleConfig.bx,box.json,bifs,models,client,sql} boxlang_modules/bx-passkeys/

box install /path/to/bx-passkeys also works, but it installs into BoxLang HOME. Run it on a checkout without the boxlang_modules/ test link (rm -rf boxlang_modules), or the copy follows the link back into itself.

Settings

Set these in boxlang.json:

"modules": {
	"bxPasskeys": {
		"settings": {
			"rpId": "example.com",
			"rpName": "Example",
			"store": { "datasource": "app", "table": "passkey_credentials" }
		}
	}
}

Or pass them straight in: passkeyRelyingParty( { rpId: "example.com" } ).

Setting Default Meaning
rpId "localhost" Your site's domain. Passkeys are bound to it, so they also work on its subdomains.
rpName the rpIdThe name shown in the browser's passkey sheet.
origins [] The exact origins sign-ins may come from, e.g. [ "https://example.com" ]. When it's empty, the module allows https on the rpId itself (any port), and plain http only on localhost.
allowSubdomains false With origins empty, also allow https on any subdomain of the rpId. Leave it off if anyone else can serve pages on a subdomain (user content, previews): such a page could relay sign-ins.
challengeSeconds 300 How long a challenge stays good. This is also the browser's timeout.
algorithms [ -7, -8, -257 ] COSE algorithm ids to accept, in order of preference.
userVerification "preferred" Set it to "required" to refuse an authenticator that didn't check a PIN or biometric.
residentKey "required" Discoverable credentials, so people can sign in without typing a username.
maxPerUser 0 The most passkeys one user may have. 0 means no limit.
sessionKey "passkeyChallenge" The key in your session struct where pending challenges are kept, one for registration and one for sign-in.
store { table, datasource } Settings for the default TableCredentialStore.

Usage

passkeyRelyingParty( overrides = {}, store ) returns a RelyingParty built from the module settings. Without a store, it uses a TableCredentialStore. You can also build one yourself with new bxModules.bxPasskeys.models.RelyingParty( settings, store ).

Every method that takes sessionData reads or writes your session struct. In a web app that's session. With BoxExpress it's req.session.

Registering a passkey (the user is already signed in)

// POST /passkeys/options
rp = passkeyRelyingParty()
return rp.registrationOptions( { id: user.id, name: user.email, displayName: user.name }, session )

// POST /passkeys        body = what createPasskey() returned
try {
	credential = rp.register( { id: user.id }, session, requestBody )
	return { id: credential.credentialId, name: credential.name }
} catch ( "Passkeys.Invalid" e ) {
	// 400, e.message is safe to show
}

user.id is your app's id for the user, sent to the authenticator as UTF-8 bytes (at most 64). Don't put names or emails in it.

Signing in

// POST /sign-in/passkey/options
return passkeyRelyingParty().signInOptions( session )

// POST /sign-in/passkey   body = what signWithPasskey() returned
try {
	result = passkeyRelyingParty().verify( session, requestBody )
	// result = { userId, credentialId, userVerified, signCount, credential }
	// sign result.userId in; rotate the session id
} catch ( "Passkeys.Invalid" e ) {
	// 401
}

To re-confirm a user who is already signed in (before a sensitive action, say), pass their id: signInOptions( session, user.id ). Only that user's passkeys are offered, and verify() refuses anyone else's.

In the browser

client/passkeys.js is a small ES module, with types in client/passkeys.d.ts. Copy it into your front end:

import { createPasskey, signWithPasskey, passkeysSupported } from './passkeys.js'

const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) }).then((r) => r.json())

// add a passkey
const registration = await createPasskey(await post('/passkeys/options'))
if (registration) await post('/passkeys', registration)

// sign in
const assertion = await signWithPasskey(await post('/sign-in/passkey/options'))
if (assertion) await post('/sign-in/passkey', assertion)

Both functions return null when the person closes the browser's sheet.

Runnable example

examples/flow.bxs registers a passkey, signs in with it and shows a replay being refused, with the test authenticator standing in for the browser:

box install && boxlang setup-tests.bxs && boxlang examples/flow.bxs

Credential stores

A store implements bxModules.bxPasskeys.models.CredentialStore:

function findByCredentialId( required string credentialId )   // struct or null
array function listForUser( required string userId )          // newest first
void function add( required struct credential )
boolean function updateUsage( required string credentialId, required numeric signCount, required date lastUsedAt )
boolean function remove( required string credentialId )

A credential is { credentialId, userId, publicKey, algorithm, signCount, transports, name, createdAt, lastUsedAt }. credentialId and publicKey (SPKI) are base64url strings. Compare credentialId and userId case-sensitively.

updateUsage must check and write in one step: save only while the new signCount is above the stored one (or both are 0), and return whether it saved. That's what keeps two sign-ins racing with the same counter from both getting through.

  • TableCredentialStore( { datasource, table } ) uses one table with plain parameterized SQL. Create the table from sql/mssql.sql, sql/mysql.sql or sql/postgres.sql. The scripts give credential_id and user_id binary collations, so they compare case-sensitively; keep that if you write your own.
  • MemoryCredentialStore() holds passkeys in memory, for tests and demos.

To list or remove a user's passkeys in your account settings, call the store directly: rp.getStore().listForUser( user.id ).

What it checks

On registration:

  • The challenge is the one in the session, for registration, still in date, and for the same user. Taking it is locked per session, so one challenge can't be used twice at once.
  • clientDataJSON has type webauthn.create, an allowed origin, and isn't from a cross-origin frame.
  • authenticatorData carries the SHA-256 of the rpId, the user-present flag (and the user-verified flag when that's required), and attested credential data.
  • The credential id matches the one in authenticatorData and is at most 1023 bytes. It's stored in canonical base64url, whatever padding the browser sent.
  • authenticatorData carries the COSE key, which is one of the accepted algorithms and can be read. When the browser also sends an SPKI key, it must be the same key.
  • The user is still under maxPerUser.
  • The passkey isn't already registered to another user.

On sign-in, the same challenge, origin, rpId and flag checks apply, plus:

  • The credential is on file.
  • It belongs to the expected user, if one was given, and matches userHandle, if the browser sent one.
  • Its algorithm is still one of the accepted algorithms.
  • The signature over authenticatorData ‖ SHA-256(clientDataJSON) verifies.
  • The signature counter moves forward, unless it's 0 and always has been (most passkeys always send 0). A counter that stays put, goes backwards or drops to 0 suggests a cloned key. The store saves the new counter only if it's still ahead, so two sign-ins racing with one counter can't both succeed.

Every refusal throws Passkeys.Invalid with a message you can show the user. A bad setting throws Passkeys.Configuration.

The module doesn't do attestation verification, authenticator metadata (MDS) or any extensions.

Development

box install                 # TestBox
boxlang setup-tests.bxs     # once: links boxlang_modules/bxPasskeys to this folder
boxlang run-tests.bxs

The table-store spec uses an in-memory Derby database, so it needs the bx-derby module (install-bx-module bx-derby).

License

MIT

Changelog

0.2.0

  • Breaking: with origins empty, only https://<rpId> is allowed now, not its subdomains. Set the new allowSubdomains: true (or list origins) to allow them.
  • Breaking: CredentialStore.updateUsage() returns a boolean and must save only while the counter moves forward (or stays 0), in one step. Custom stores need updating.
  • A cloned key that sent counter 0 got past the clone check and reset the stored counter to 0. Any counter that doesn't move forward is now refused.
  • Two sign-ins racing with the same counter can't both succeed: TableCredentialStore updates with sign_count < ?, and the memory store checks under its lock.
  • Taking a challenge is locked per session, so one assertion can't be used by two parallel requests.
  • Registration and sign-in keep separate challenges, so starting a sign-in (in another tab, say) no longer cancels a registration in progress.
  • Registration requires the COSE key in authenticatorData; a browser SPKI key is checked against it rather than trusted on its own.
  • Credential ids over 1023 bytes are refused, ids are stored in canonical base64url, and transports are capped at 10 distinct entries.
  • maxPerUser is checked at registration too.
  • Sign-in refuses passkeys whose algorithm was taken out of algorithms.
  • SQL scripts: binary collations on credential_id and user_id (MSSQL, MySQL); name is bound as nvarchar so non-ASCII names survive on MSSQL.
  • MemoryCredentialStore matches user ids exactly and reads under its lock.
  • Module defaults live only in RelyingParty; one SecureRandom and Base64 coder are shared.

0.1.0

  • First release, extracted from DismalThreads.
  • WebAuthn registration and sign-in with attestation "none", using only JDK crypto (java.security).
  • ES256 (P-256), Ed25519 and RS256 keys, read from the browser's SPKI getPublicKey() or the COSE key in attestationObject (built-in CBOR decoder).
  • Single-use, expiring challenges kept in a session struct you pass in.
  • Pluggable credential stores: TableCredentialStore and MemoryCredentialStore ship with the module.
  • passkeyRelyingParty() BIF and a browser client helper.

$ box install bx-passkeys

No collaborators yet.
 
  • {{ getFullDate("2026-11-06T14:46:39-06:00") }}
  • {{ getFullDate("2026-11-06T18:04:53-06:00") }}
  • 33
  • 8