Passkey Authentication in Grails 7: A Practical Guide to Killing the Password

Your users are still typing passwords. In 2026, that is both a security liability and a UX failure. Here is how to add passkey authentication to an existing Grails application with minimal changes to your current login flow.
What you’ll learn: How WebAuthn and passkeys work under the hood, configuring Yubico’s
java-webauthn-serverlibrary as a Spring bean in Grails 7, building a GORM-backed credential repository, implementing the registration and authentication ceremonies in a Grails controller, writing the browser-side JavaScript fornavigator.credentials.create()andnavigator.credentials.get(), prompting existing users to enroll a passkey after password login, and the configuration gotchas that will break everything in production. A complete working CRM example with passkey support is available on GitHub.
Passkeys have reached the point where every major browser and operating system supports them, every large identity provider promotes them, and yet most application developers have never implemented one. The reason is not that the concept is difficult — it is that the WebAuthn specification is dense, the terminology is confusing, and the handful of tutorials that exist tend to either stop at “register a credential in memory” or assume you are building a greenfield Node.js application.
This guide is for the developer who has an existing Grails application with password-based Spring Security login and wants to add passkey support as an upgrade path — not a replacement. Users who have enrolled a passkey can sign in with whatever verification method their device supports — a fingerprint scan on a MacBook, Face ID on an iPhone, a Windows Hello PIN, or a tap on a hardware security key — instead of typing a password. Users who have not enrolled yet continue to use their password, and are gently prompted to set up a passkey after they log in. The existing authentication flow is untouched. No migration. No forced adoption. Just a strictly better option alongside what already works.
We will build this on top of a real application — a simple CRM built with Grails 7 and the Spring Security Core plugin — not a toy registration form. By the end of this article, you will have a mental model for how WebAuthn works, a complete server-side implementation using Yubico’s java-webauthn-server library, the browser JavaScript to drive both ceremonies, and the Grails-specific wiring that makes it all fit together.
Why Passkeys, and Why Now
Before we write any code, it is worth understanding what passkeys actually solve and why the timing matters.
A passkey is a WebAuthn credential — a cryptographic key pair where the private key lives on the user’s device (or synced across their devices via their platform’s credential manager) and the public key lives on your server. When the user authenticates, their device signs a challenge from your server with the private key, and your server verifies the signature with the stored public key. The private key never leaves the device. There is nothing to phish, nothing to stuff, nothing to leak in a database breach.
The practical shift that makes passkeys viable today — as opposed to five years ago when WebAuthn first appeared — is synchronisation. Apple, Google, and Microsoft all now sync passkeys across devices through iCloud Keychain, Google Password Manager, and Windows Hello respectively. This eliminates the old FIDO2 pain point where a hardware security key was the only option and losing it meant losing access. A user who creates a passkey on their iPhone can use it on their iPad, their Mac, and even on a Windows PC via a cross-device QR code flow. The credential follows the user, not the device.
What Counts as an “Authenticator”
A common misconception is that passkeys require biometric hardware — a fingerprint reader or face scanner. They do not. The WebAuthn specification requires user verification, which means the authenticator must confirm that the person using it is the legitimate owner, but it is deliberately agnostic about how that verification happens. In practice, the authenticator is whatever your operating system or hardware provides:
- macOS / iOS: Touch ID (fingerprint), Face ID, or the device passcode as a fallback.
- Windows: Windows Hello, which supports fingerprint, facial recognition, and a PIN. The PIN option is important — it means any modern Windows machine can use passkeys, even without biometric hardware. Windows Hello PINs are not like ordinary passwords: they are device-bound, never transmitted over the network, and backed by the TPM (Trusted Platform Module) chip.
- Android: Fingerprint, face unlock, or the device screen lock (pattern, PIN, or password).
- Hardware security keys: USB devices like YubiKeys, which verify via a physical button press or an on-device fingerprint sensor. These are cross-platform and work on any machine with a USB port (or NFC on mobile).
- Third-party password managers: Tools like 1Password and Bitwarden now act as passkey providers, storing and syncing credentials across platforms and authenticating via their own unlock mechanism (master password, biometric, or both).
From your server’s perspective, all of these look identical. The WebAuthn protocol abstracts the verification method entirely — your server sends a challenge, receives a signed response, and verifies the signature. It never knows (or needs to know) whether the user touched a fingerprint sensor, entered a Windows Hello PIN, or tapped a YubiKey. This is a deliberate design choice that makes passkeys work across the widest possible range of devices and user preferences.
For a Grails application that already uses Spring Security with username/password login, the integration strategy is straightforward. You keep your existing authentication chain entirely intact. You add a parallel path: a set of endpoints that handle the WebAuthn registration and authentication ceremonies, a domain class that stores credentials, and a small amount of JavaScript on the login page and the post-login dashboard. The two paths coexist. Users choose which one to use.
What We Are Building
This tutorial builds on the Tucanoo Simple CRM, a Grails application with customer management, Spring Security login, and an H2 database. We are going to add:
A passkey enrollment flow — after logging in with a password, users who have no passkeys see a banner prompting them to create one. They click a button, their browser triggers a verification prompt (fingerprint, face, PIN, or security key — whatever their device supports), and the credential is stored in the database.
A passkey sign-in flow — on the login page, a “Sign in with passkey” button triggers the browser’s credential selector. The user picks their passkey and completes device verification, and they are authenticated without ever typing a password.
A GORM domain class for persisted passkey credentials, a Grails service that orchestrates the Yubico library, a controller that exposes the registration and authentication API, and the GSP/JavaScript front end that drives it all.
The companion repository is at tucanoo/grails_passkey_tutorial.
How WebAuthn Works: The Two Ceremonies
WebAuthn defines two operations: registration (the spec calls it “attestation”) and authentication (the spec calls it “assertion”). Both follow the same two-phase pattern: the server generates a challenge, the browser’s WebAuthn API does the cryptographic work with the user’s authenticator, and the browser sends the result back to the server for verification. Understanding this flow is essential before looking at any code.
Registration (Creating a Passkey)
Server → Browser: Your server calls the Yubico library’s
startRegistration()method, which generates a JSON object containing a random challenge, your Relying Party identity (your app’s domain), the user’s details, and the cryptographic algorithms you accept. You send this to the browser.Browser → Authenticator: Your JavaScript passes this object to
navigator.credentials.create(). The browser prompts the user to verify their identity — fingerprint, face scan, PIN, whatever their device supports. The authenticator generates a new public/private key pair, signs the challenge with the private key, and returns the signed response plus the public key.Browser → Server: Your JavaScript sends the authenticator’s response back to your server.
Server verification: Your server calls
finishRegistration(), which verifies the signature, checks the challenge matches, validates the origin, and returns the public key and credential ID. You store these in your database.
Authentication (Signing In with a Passkey)
Server → Browser: Your server calls
startAssertion(), which generates a new random challenge. If you know which user is trying to log in, you can include their registered credential IDs to narrow the selection. For a true passwordless flow, you omit them and let the browser offer all available passkeys for your domain.Browser → Authenticator: Your JavaScript passes this to
navigator.credentials.get(). The browser shows available passkeys for your domain. The user selects one and verifies their identity. The authenticator signs the challenge with the private key.Browser → Server: Your JavaScript sends the signed response back to your server.
Server verification: Your server calls
finishAssertion(), which looks up the credential by ID, verifies the signature against the stored public key, checks the challenge and origin, and returns the authenticated user’s identity.
The critical thing to understand is that the server never sees the private key. It only ever sees the public key (at registration) and signatures (at authentication). The cryptographic verification is what proves the user possesses the private key, without transmitting it.
Project Setup
Prerequisites
- Java 17+ (the tutorial repo targets Java 25, but 17+ works with Grails 7)
- Grails 7.0.7 (included via the Gradle wrapper — no separate installation required)
- A modern browser (Chrome, Safari, Firefox, Edge — all support WebAuthn)
Dependencies
The key addition to build.gradle beyond the standard Grails 7 setup is the Yubico WebAuthn server library:
dependencies {
// Grails and Spring Security (already in the CRM project)
implementation 'org.apache.grails:grails-spring-security:7.0.0'
// Yubico WebAuthn server library — this is the core addition
implementation 'com.yubico:webauthn-server-core:2.8.1'
}The Yubico library is stateless, thread-safe, and has no opinions about your database or web framework. It provides four methods on a RelyingParty object — startRegistration, finishRegistration, startAssertion, finishAssertion — and leaves everything else to you. This is exactly what we want for a Grails integration: the library handles the cryptography, and we handle the GORM persistence and controller routing.
Why Yubico’s Library and Not Spring Security’s Built-in WebAuthn?
If you have been following Spring Security releases, you may know that version 6.4 added native WebAuthn support via the http.webAuthn() DSL on the HttpSecurity builder. Grails 7 ships with Spring Boot 3.5.6, which pulls in Spring Security 6.5 — so those WebAuthn classes are sitting right there on your classpath. Why not use them?
The issue is the Grails Spring Security Core plugin. The plugin manages the entire SecurityFilterChain programmatically through its own configuration layer — the grails.plugin.springsecurity.* properties in application.yml and application.groovy. It explicitly excludes Spring Boot‘s security auto-configuration classes to prevent conflicts. You never touch HttpSecurity directly in a Grails application; the plugin does that for you. Spring Security’s native WebAuthn support is wired through http.webAuthn() on the HttpSecurity builder, but the Grails plugin does not expose any grails.plugin.springsecurity.webauthn.* properties to surface it. You could define your own SecurityFilterChain bean and call http.webAuthn() yourself, but then you are fighting the plugin’s filter chain management and risk breaking the existing password login, @Secured annotations, and session handling that the plugin already manages correctly.
Yubico’s library sidesteps this entirely. It is a pure computation library — you pass it data, it returns verified results. It does not touch the servlet filter chain, does not register any Spring beans automatically, and does not care how your HTTP layer works. We wire it into Grails as a plain Spring bean, build our own controller endpoints, and use the existing springSecurityService.reauthenticate() to establish sessions after a successful passkey authentication. The plugin’s filter chain is never modified.
This also means the approach in this tutorial is not limited to Grails 7. Because we are not depending on any specific Spring Security version for WebAuthn support, the same architecture — Yubico library, GORM credential repository, custom controller endpoints — works with any version of Grails that uses the Spring Security Core plugin, all the way back to Grails 2. The domain class syntax, configuration format, and plugin package names vary across major Grails versions, but the pattern is identical: configure a RelyingParty bean, implement the CredentialRepository interface with GORM queries, expose the four ceremony endpoints in a controller, and call springSecurityService.reauthenticate() on success. The Yubico library’s only JVM requirement is Java 8+. If your application has Spring Security and a database, you can add passkeys this way.
Configuration
Add the passkey configuration to grails-app/conf/application.yml:
passkeys:
enabled: true
rpId: localhost
rpName: Simple CRM
origins:
- http://localhost:8080
- http://127.0.0.1:8080
attestation: none
allowUsernameless: false
challengeTtlSeconds: 300
maxCredentialsPerUser: 5A few things to note here:
rpIdis the Relying Party identifier — this must match the domain your app is served from. For local development,localhostworks because browsers treat it as a secure context for WebAuthn. In production, this would beyourdomain.com(no scheme, no port, just the domain).originsmust include the full origin (scheme + host + port) of your application. The WebAuthn API checks the origin in the client data against this list. If they do not match, verification fails silently and you will spend an hour staring at unhelpful error messages.attestation: nonemeans we do not ask the authenticator to prove its make and model. Unless you are building for a regulated environment that requires specific authenticator hardware,noneis the right choice — it maximises compatibility and simplifies the flow.allowUsernameless: falsemeans we require users to identify themselves (select a passkey associated with their account) rather than supporting fully discoverable credentials where the authenticator identifies the user. This keeps things simpler for a first implementation.
The Domain Model: Storing Passkey Credentials
Each passkey a user registers needs to be persisted. We create a GORM domain class that stores the essential fields the Yubico library needs for authentication:
// grails-app/domain/com/tucanoo/security/PasskeyCredential.groovy
package com.tucanoo.security
class PasskeyCredential {
String username // The user who owns this credential
byte[] credentialId // Unique ID generated by the authenticator
byte[] publicKeyCose // The public key in COSE format
byte[] userHandle // Opaque user handle (WebAuthn user.id)
long signatureCount // Counter to detect cloned authenticators
String credentialNickname // User-friendly label ("My MacBook", "Work Phone")
boolean revoked = false // Soft-delete — revoke rather than remove
Date dateCreated
Date lastUpdated
Date lastUsed
static constraints = {
username blank: false
credentialId maxSize: 1024
publicKeyCose maxSize: 2048
userHandle maxSize: 64
credentialNickname nullable: true, blank: true
lastUsed nullable: true
}
static mapping = {
credentialId column: 'credential_id', sqlType: 'varbinary(1024)'
publicKeyCose column: 'public_key_cose', sqlType: 'varbinary(2048)'
userHandle column: 'user_handle', sqlType: 'varbinary(64)'
}
}The credentialId is what the browser sends back during authentication to identify which credential was used. The publicKeyCose is the public key in CBOR Object Signing and Encryption format — the format the Yubico library expects. The signatureCount is an anti-cloning measure: each time an authenticator is used, it increments this counter, and the server checks that the new count is higher than the stored one.
The userHandle is an important but often misunderstood field. It is an opaque byte array that your server generates during registration and the authenticator returns during authentication. It is how the server identifies which user a credential belongs to, and it must not contain personally identifiable information (the spec is explicit about this). We generate it as a random byte array when a user first enrolls.
The Credential Repository: Bridging Yubico and GORM
The Yubico library needs a way to look up credentials without knowing anything about your database. It defines a CredentialRepository interface that you implement with your storage layer. This is the bridge between the Yubico library and GORM:
// src/main/groovy/com/tucanoo/security/passkey/PasskeyCredentialRepository.groovy
package com.tucanoo.security.passkey
import com.tucanoo.security.PasskeyCredential
import com.yubico.webauthn.CredentialRepository
import com.yubico.webauthn.RegisteredCredential
import com.yubico.webauthn.data.ByteArray
import com.yubico.webauthn.data.PublicKeyCredentialDescriptor
import com.yubico.webauthn.data.PublicKeyCredentialType
class PasskeyCredentialRepository implements CredentialRepository {
/**
* Look up credential IDs by username — used during authentication
* to tell the browser which credentials are valid for this user.
*/
@Override
Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(String username) {
def credentials = PasskeyCredential.findAllByUsernameAndRevoked(username, false)
credentials.collect { cred ->
PublicKeyCredentialDescriptor.builder()
.id(new ByteArray(cred.credentialId))
.type(PublicKeyCredentialType.PUBLIC_KEY)
.build()
}.toSet()
}
/**
* Look up user handle by username — used during authentication.
*/
@Override
Optional<ByteArray> getUserHandleForUsername(String username) {
def cred = PasskeyCredential.findByUsernameAndRevoked(username, false)
if (cred) {
return Optional.of(new ByteArray(cred.userHandle))
}
return Optional.empty()
}
/**
* Reverse lookup: find username from a user handle.
* The authenticator returns the user handle during authentication.
*/
@Override
Optional<String> getUsernameForUserHandle(ByteArray userHandle) {
def cred = PasskeyCredential.findByUserHandleAndRevoked(
userHandle.bytes, false
)
if (cred) {
return Optional.of(cred.username)
}
return Optional.empty()
}
/**
* Look up a specific credential by credential ID — used during
* authentication to retrieve the public key for signature verification.
*/
@Override
Optional<RegisteredCredential> lookup(ByteArray credentialId,
ByteArray userHandle) {
def cred = PasskeyCredential.findByCredentialIdAndRevoked(
credentialId.bytes, false
)
if (cred) {
return Optional.of(
RegisteredCredential.builder()
.credentialId(new ByteArray(cred.credentialId))
.userHandle(new ByteArray(cred.userHandle))
.publicKeyCose(new ByteArray(cred.publicKeyCose))
.signatureCount(cred.signatureCount)
.build()
)
}
return Optional.empty()
}
/**
* Look up all credentials for a credential ID — required by the interface.
*/
@Override
Set<RegisteredCredential> lookupAll(ByteArray credentialId) {
def credentials = PasskeyCredential.findAllByCredentialIdAndRevoked(
credentialId.bytes, false
)
credentials.collect { cred ->
RegisteredCredential.builder()
.credentialId(new ByteArray(cred.credentialId))
.userHandle(new ByteArray(cred.userHandle))
.publicKeyCose(new ByteArray(cred.publicKeyCose))
.signatureCount(cred.signatureCount)
.build()
}.toSet()
}
}Each method maps directly to a GORM query. The Yubico library calls these during finishRegistration and finishAssertion to look up stored credentials. The RegisteredCredential builder is how you hand the library the data it needs — credential ID, user handle, public key, and current signature count — without exposing your domain model.
Note that we always filter by revoked: false. This means revoking a passkey is as simple as setting a boolean — no deletion required, and you keep an audit trail.
The Challenge Cache: Temporary State Between Phases
Both WebAuthn ceremonies are two-phase: the server generates a challenge in phase one, and verifies the response in phase two. The challenge and associated options need to be stored temporarily between these two HTTP requests. There are several ways to handle this — HTTP session, a database table, an in-memory cache. For this tutorial, we use a simple time-limited in-memory cache:
// src/main/groovy/com/tucanoo/security/passkey/PasskeyChallengeCache.groovy
package com.tucanoo.security.passkey
import java.util.concurrent.ConcurrentHashMap
class PasskeyChallengeCache {
private final Map<String, CacheEntry> store = new ConcurrentHashMap<>()
private final long ttlMillis
PasskeyChallengeCache(long ttlSeconds) {
this.ttlMillis = ttlSeconds * 1000
}
void put(String key, Object value) {
evictExpired()
store.put(key, new CacheEntry(value, System.currentTimeMillis()))
}
Optional<Object> get(String key) {
def entry = store.get(key)
if (entry && (System.currentTimeMillis() - entry.timestamp) < ttlMillis) {
store.remove(key) // Single use — remove after retrieval
return Optional.of(entry.value)
}
store.remove(key)
return Optional.empty()
}
private void evictExpired() {
long now = System.currentTimeMillis()
store.entrySet().removeIf { e ->
(now - e.value.timestamp) >= ttlMillis
}
}
private static class CacheEntry {
final Object value
final long timestamp
CacheEntry(Object value, long timestamp) {
this.value = value
this.timestamp = timestamp
}
}
}The cache is single-use: once a challenge is retrieved for verification, it is removed. This prevents replay attacks. The TTL (defaulting to 300 seconds from our config) ensures stale challenges do not accumulate. In a clustered deployment you would want a shared store like Redis instead, but for a single-instance Grails application this is perfectly adequate.
Configuring the RelyingParty Bean
The Yubico library’s RelyingParty object is the main entry point for all WebAuthn operations. It is stateless and thread-safe, so we configure it as a Spring bean. The configuration class reads our application.yml settings and wires everything together:
// src/main/groovy/com/tucanoo/security/passkey/PasskeyConfiguration.groovy
package com.tucanoo.security.passkey
import com.yubico.webauthn.RelyingParty
import com.yubico.webauthn.data.RelyingPartyIdentity
import grails.core.GrailsApplication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class PasskeyConfiguration {
@Bean
PasskeyCredentialRepository passkeyCredentialRepository() {
new PasskeyCredentialRepository()
}
@Bean
PasskeyChallengeCache passkeyChallengeCache(GrailsApplication grailsApplication) {
def ttl = grailsApplication.config.getProperty(
'passkeys.challengeTtlSeconds', Integer, 300
)
new PasskeyChallengeCache(ttl)
}
@Bean
RelyingParty relyingParty(GrailsApplication grailsApplication,
PasskeyCredentialRepository credentialRepository) {
def config = grailsApplication.config
def rpId = config.getProperty('passkeys.rpId', String, 'localhost')
def rpName = config.getProperty('passkeys.rpName', String, 'Grails App')
def origins = config.getProperty('passkeys.origins', List, ['http://localhost:8080'])
def rpIdentity = RelyingPartyIdentity.builder()
.id(rpId)
.name(rpName)
.build()
RelyingParty.builder()
.identity(rpIdentity)
.credentialRepository(credentialRepository)
.origins(origins.collect { it.toString() }.toSet())
.allowOriginPort(true)
.build()
}
}Then register the beans in grails-app/conf/spring/resources.groovy so Grails picks them up:
// grails-app/conf/spring/resources.groovy
import com.tucanoo.security.passkey.PasskeyConfiguration
beans = {
importBeans(PasskeyConfiguration)
}The allowOriginPort(true) setting is worth noting — it relaxes origin matching to accept any port for the configured domain. During development you might run on port 8080, 8443, or something else, and this prevents false rejections. In production, you would typically pin this to your exact origin.
The Passkey Service: Orchestrating the Ceremonies
The service layer coordinates between the controller, the Yubico library, and the database. It encapsulates the ceremony logic and keeps the controller thin:
// grails-app/services/com/tucanoo/security/passkey/PasskeyService.groovy
package com.tucanoo.security.passkey
import com.tucanoo.security.PasskeyCredential
import com.yubico.webauthn.FinishAssertionOptions
import com.yubico.webauthn.FinishRegistrationOptions
import com.yubico.webauthn.RelyingParty
import com.yubico.webauthn.StartAssertionOptions
import com.yubico.webauthn.StartRegistrationOptions
import com.yubico.webauthn.data.*
import com.yubico.webauthn.exception.AssertionFailedException
import com.yubico.webauthn.exception.RegistrationFailedException
import grails.gorm.transactions.Transactional
import groovy.util.logging.Slf4j
@Slf4j
@Transactional
class PasskeyService {
RelyingParty relyingParty
PasskeyChallengeCache passkeyChallengeCache
/**
* Phase 1 of registration: generate options for the browser.
*/
String startRegistration(String username) {
def userIdentity = UserIdentity.builder()
.name(username)
.displayName(username)
.id(generateOrRetrieveUserHandle(username))
.build()
def options = relyingParty.startRegistration(
StartRegistrationOptions.builder()
.user(userIdentity)
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.authenticatorAttachment(AuthenticatorAttachment.PLATFORM)
.residentKey(ResidentKeyRequirement.PREFERRED)
.userVerification(UserVerificationRequirement.REQUIRED)
.build())
.build()
)
// Cache the options for verification in phase 2
passkeyChallengeCache.put("reg:${username}", options)
// Return as JSON for the browser
return options.toCredentialsCreateJson()
}
/**
* Phase 2 of registration: verify the browser's response and store.
*/
boolean finishRegistration(String username, String responseJson) {
def cachedOptions = passkeyChallengeCache.get("reg:${username}")
if (!cachedOptions.isPresent()) {
log.warn("No cached registration options for user: {}", username)
return false
}
try {
def pkc = PublicKeyCredential.parseRegistrationResponseJson(responseJson)
def result = relyingParty.finishRegistration(
FinishRegistrationOptions.builder()
.request(cachedOptions.get())
.response(pkc)
.build()
)
// Store the credential in the database
def credential = new PasskeyCredential(
username: username,
credentialId: result.keyId.id.bytes,
publicKeyCose: result.publicKeyCose.bytes,
userHandle: cachedOptions.get().user.id.bytes,
signatureCount: result.signatureCount,
credentialNickname: "Passkey"
)
credential.save(flush: true, failOnError: true)
log.info("Passkey registered for user: {}", username)
return true
} catch (RegistrationFailedException e) {
log.error("Registration failed for {}: {}", username, e.message)
return false
}
}
/**
* Phase 1 of authentication: generate assertion options.
*/
String startAuthentication(String username) {
def startOptions = StartAssertionOptions.builder()
// If we know the username, limit to their credentials
if (username) {
startOptions.username(username)
}
def options = relyingParty.startAssertion(startOptions.build())
// Cache keyed by challenge to handle the stateless lookup
def challengeKey = "auth:${options.publicKeyCredentialRequestOptions
.challenge.base64Url}"
passkeyChallengeCache.put(challengeKey, options)
return options.toCredentialsGetJson()
}
/**
* Phase 2 of authentication: verify and return the authenticated username.
*/
String finishAuthentication(String responseJson, String challengeBase64) {
def cachedOptions = passkeyChallengeCache.get("auth:${challengeBase64}")
if (!cachedOptions.isPresent()) {
log.warn("No cached authentication options for challenge")
return null
}
try {
def pkc = PublicKeyCredential.parseAssertionResponseJson(responseJson)
def result = relyingParty.finishAssertion(
FinishAssertionOptions.builder()
.request(cachedOptions.get())
.response(pkc)
.build()
)
if (result.success) {
// Update the signature counter
def cred = PasskeyCredential.findByCredentialId(
result.credential.credentialId.bytes
)
if (cred) {
cred.signatureCount = result.signatureCount
cred.lastUsed = new Date()
cred.save(flush: true)
}
return result.credential.userHandle
? getUsernameForHandle(result.credential.userHandle)
: result.username
}
return null
} catch (AssertionFailedException e) {
log.error("Authentication failed: {}", e.message)
return null
}
}
/**
* Check if a user has any active passkeys.
*/
boolean hasPasskeys(String username) {
PasskeyCredential.countByUsernameAndRevoked(username, false) > 0
}
/**
* Generate a new random user handle, or retrieve the existing one
* if the user already has passkeys registered.
*/
private ByteArray generateOrRetrieveUserHandle(String username) {
def existing = PasskeyCredential.findByUsernameAndRevoked(username, false)
if (existing) {
return new ByteArray(existing.userHandle)
}
// Generate a random 32-byte handle
def bytes = new byte[32]
new java.security.SecureRandom().nextBytes(bytes)
return new ByteArray(bytes)
}
private String getUsernameForHandle(ByteArray userHandle) {
def cred = PasskeyCredential.findByUserHandleAndRevoked(
userHandle.bytes, false
)
return cred?.username
}
}There are a few important design decisions here worth explaining.
AuthenticatorAttachment.PLATFORM tells the browser to prefer the device’s built-in authenticator — Touch ID, Face ID, Windows Hello (which includes fingerprint, facial recognition, and PIN), or the Android screen lock. This is the right default for a “passkey first” experience aimed at mainstream users, because it steers toward the authenticators people already use to unlock their devices. It does not mean biometrics only — a Windows user without a fingerprint reader will be offered their Windows Hello PIN, which is perfectly valid and equally secure from the server’s perspective. If you also want to support roaming hardware security keys like YubiKeys alongside platform authenticators, remove this constraint entirely by omitting the authenticatorAttachment parameter. The browser will then offer all available authenticator types and let the user choose.
ResidentKeyRequirement.PREFERRED asks the authenticator to create a discoverable credential (a “passkey” in the consumer sense) if it can, but does not fail if it cannot. This is the safe default.
UserVerificationRequirement.REQUIRED means the user must complete device-level identity verification before the credential is used — a biometric check, a Windows Hello PIN, a device passcode, or a button press on a security key with user verification support. The specific method is chosen by the authenticator, not by your server. Never set this to DISCOURAGED unless you have a very specific reason — without user verification, the credential proves device possession but not user identity.
The challenge cache key for authentication uses the challenge itself (base64url-encoded) rather than the username, because in a passwordless flow you may not know the username at the start of the ceremony. The challenge is unique per request and serves as a natural correlation key.
The Controller: Exposing the API
The PasskeyController provides the four endpoints that the browser JavaScript calls — two for registration, two for authentication. It also handles the post-authentication session setup:
// grails-app/controllers/com/tucanoo/security/passkey/PasskeyController.groovy
package com.tucanoo.security.passkey
import grails.converters.JSON
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
class PasskeyController {
PasskeyService passkeyService
SpringSecurityService springSecurityService
/**
* Registration Phase 1: generate credential creation options.
* Requires the user to already be logged in (they're adding a passkey
* to their existing account).
*/
@Secured(['ROLE_CRM_USER'])
def registerOptions() {
def username = springSecurityService.principal.username
def json = passkeyService.startRegistration(username)
render(contentType: 'application/json', text: json)
}
/**
* Registration Phase 2: verify and store the new credential.
*/
@Secured(['ROLE_CRM_USER'])
def registerComplete() {
def username = springSecurityService.principal.username
def responseJson = request.JSON.toString()
def success = passkeyService.finishRegistration(username, responseJson)
render([success: success] as JSON)
}
/**
* Authentication Phase 1: generate assertion options.
* This endpoint is public — it's on the login page.
*/
@Secured(['permitAll'])
def authOptions() {
def json = passkeyService.startAuthentication(null)
render(contentType: 'application/json', text: json)
}
/**
* Authentication Phase 2: verify the assertion and log the user in.
*/
@Secured(['permitAll'])
def authComplete() {
def body = request.JSON
def responseJson = body.credential.toString()
def challengeBase64 = body.challenge?.toString()
def username = passkeyService.finishAuthentication(
responseJson, challengeBase64
)
if (username) {
// Programmatically authenticate via Spring Security
springSecurityService.reauthenticate(username)
render([success: true, redirect: '/dashboard/index'] as JSON)
} else {
response.status = 401
render([success: false, error: 'Authentication failed'] as JSON)
}
}
}The registration endpoints are secured — only an already-authenticated user can register a passkey against their account. This prevents an attacker from registering a passkey on someone else’s account.
The authentication endpoints are public — they are accessed from the login page before the user is authenticated. The authComplete endpoint is where the magic happens: if the Yubico library verifies the assertion successfully, we call springSecurityService.reauthenticate(username) to establish a Spring Security session, exactly as if the user had logged in with a password. From that point on, all the existing @Secured annotations, role checks, and session management work identically.
You also need to add the URL mappings for these endpoints. In grails-app/controllers/UrlMappings.groovy, add:
"/passkey/register/options"(controller: 'passkey', action: 'registerOptions')
"/passkey/register/complete"(controller: 'passkey', action: 'registerComplete')
"/passkey/auth/options"(controller: 'passkey', action: 'authOptions')
"/passkey/auth/complete"(controller: 'passkey', action: 'authComplete')And make sure these URLs are accessible through Spring Security. In application.groovy or application.yml:
grails:
plugin:
springsecurity:
controllerAnnotations:
staticRules:
- pattern: '/passkey/auth/**'
access: ['permitAll']The Browser Side: JavaScript for Both Ceremonies
The WebAuthn JavaScript API is built into every modern browser — there are no libraries to install. However, the data formats involve ArrayBuffer and Uint8Array types that need base64url encoding/decoding to travel over JSON. Here is the JavaScript that drives both ceremonies:
Registration (on the Dashboard page, after password login)
async function registerPasskey() {
try {
// Phase 1: Get creation options from the server
const optionsResponse = await fetch('/passkey/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const creationOptionsJson = await optionsResponse.text();
// The Yubico library's toCredentialsCreateJson() output is
// directly consumable by the WebAuthn API with base64url decoding
const creationOptions = JSON.parse(creationOptionsJson);
// Decode base64url fields to ArrayBuffers
creationOptions.publicKey.challenge = base64urlToBuffer(
creationOptions.publicKey.challenge
);
creationOptions.publicKey.user.id = base64urlToBuffer(
creationOptions.publicKey.user.id
);
if (creationOptions.publicKey.excludeCredentials) {
creationOptions.publicKey.excludeCredentials.forEach(cred => {
cred.id = base64urlToBuffer(cred.id);
});
}
// Phase 2: Trigger the browser's WebAuthn prompt
const credential = await navigator.credentials.create(creationOptions);
// Encode the response for JSON transport
const registrationResponse = {
id: credential.id,
type: credential.type,
rawId: bufferToBase64url(credential.rawId),
response: {
attestationObject: bufferToBase64url(
credential.response.attestationObject
),
clientDataJSON: bufferToBase64url(
credential.response.clientDataJSON
)
}
};
// Send to server for verification
const verifyResponse = await fetch('/passkey/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registrationResponse)
});
const result = await verifyResponse.json();
if (result.success) {
document.getElementById('passkey-banner').style.display = 'none';
alert('Passkey registered successfully! Next time you can sign in '
+ 'without a password — just use your device's unlock method.');
} else {
alert('Registration failed. Please try again.');
}
} catch (err) {
if (err.name === 'NotAllowedError') {
console.log('User cancelled the passkey registration');
} else {
console.error('Passkey registration error:', err);
alert('An error occurred during passkey registration.');
}
}
}Authentication (on the Login page)
async function signInWithPasskey() {
try {
// Phase 1: Get assertion options from the server
const optionsResponse = await fetch('/passkey/auth/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const assertionOptionsJson = await optionsResponse.text();
const assertionOptions = JSON.parse(assertionOptionsJson);
// Save the challenge for sending back to the server
const challengeBase64 = assertionOptions.publicKey.challenge;
// Decode base64url fields
assertionOptions.publicKey.challenge = base64urlToBuffer(
assertionOptions.publicKey.challenge
);
if (assertionOptions.publicKey.allowCredentials) {
assertionOptions.publicKey.allowCredentials.forEach(cred => {
cred.id = base64urlToBuffer(cred.id);
});
}
// Phase 2: Trigger the browser's passkey selector
const credential = await navigator.credentials.get(assertionOptions);
// Encode the response
const assertionResponse = {
challenge: challengeBase64,
credential: {
id: credential.id,
type: credential.type,
rawId: bufferToBase64url(credential.rawId),
response: {
authenticatorData: bufferToBase64url(
credential.response.authenticatorData
),
clientDataJSON: bufferToBase64url(
credential.response.clientDataJSON
),
signature: bufferToBase64url(
credential.response.signature
),
userHandle: credential.response.userHandle
? bufferToBase64url(credential.response.userHandle)
: null
}
}
};
// Send to server for verification
const verifyResponse = await fetch('/passkey/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertionResponse)
});
const result = await verifyResponse.json();
if (result.success) {
window.location.href = result.redirect;
} else {
alert('Passkey authentication failed. Please try password login.');
}
} catch (err) {
if (err.name === 'NotAllowedError') {
console.log('User cancelled passkey sign-in');
} else {
console.error('Passkey authentication error:', err);
alert('An error occurred. Please try password login.');
}
}
}
// Utility functions for base64url <-> ArrayBuffer conversion
function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
bytes.forEach(b => binary += String.fromCharCode(b));
return btoa(binary).replace(/+/g, '-').replace(///g, '_').replace(/=+$/, '');
}The base64urlToBuffer and bufferToBase64url functions handle the encoding that the WebAuthn API requires. The browser’s navigator.credentials API works with ArrayBuffer objects, but JSON transport requires string encoding. Base64url (RFC 4648 §5) is the WebAuthn standard encoding — it is base64 with + replaced by -, / replaced by _, and no padding.
The NotAllowedError catch is important: this is what the browser throws when the user cancels the WebAuthn prompt (dismisses the verification dialog, clicks “Cancel” on the passkey selector). It is not an error condition — it is a user choice. Handle it gracefully.
The Login Page: Adding the Passkey Button
The login GSP needs a button that triggers the passkey authentication flow alongside the existing password form. The key UX principle is that passkey sign-in should be prominent but not forced:
<%-- grails-app/views/login/auth.gsp — relevant additions --%>
<%-- Existing password login form stays exactly as it is --%>
<form action="${postUrl}" method="POST" id="loginForm">
<input type="text" name="${usernameParameter}" placeholder="Username" />
<input type="password" name="${passwordParameter}" placeholder="Password" />
<button type="submit">Sign In</button>
</form>
<%-- Passkey option — shown only if the browser supports WebAuthn --%>
<div id="passkey-login-section" style="display: none;">
<hr />
<p>Or sign in faster:</p>
<button type="button" onclick="signInWithPasskey()" id="passkey-login-btn">
🔑 Sign in with passkey
</button>
</div>
<script>
// Feature detection: only show the passkey button if WebAuthn is supported
if (window.PublicKeyCredential) {
document.getElementById('passkey-login-section').style.display = 'block';
// Optional: check if a platform authenticator is available
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(available => {
if (!available) {
document.getElementById('passkey-login-btn').textContent =
'🔑 Sign in with security key';
}
});
}
</script>
<asset:javascript src="passkey-auth.js" />The feature detection is crucial. window.PublicKeyCredential exists in all modern browsers that support WebAuthn. The additional isUserVerifyingPlatformAuthenticatorAvailable() check tells you whether the device has a built-in authenticator — this includes fingerprint readers, Face ID, and Windows Hello (even PIN-only setups, since Windows Hello is backed by the TPM). If no platform authenticator is available, the user would need an external security key, and you can adjust the button text accordingly.

The Post-Login Enrollment Prompt
When a user logs in with a password and has no passkeys registered, the dashboard shows a banner encouraging them to set one up. This is the adoption mechanism — you do not force passkeys on anyone, but you make it easy for interested users to upgrade:
<%-- grails-app/views/dashboard/index.gsp — enrollment banner --%>
<g:if test="${showPasskeyBanner}">
<div id="passkey-banner" class="alert alert-info">
<strong>🔑 Upgrade your login experience</strong>
<p>
Set up a passkey to sign in without a password next time —
just use your fingerprint, face, PIN, or security key.
</p>
<button type="button" onclick="registerPasskey()" class="btn btn-primary">
Create passkey
</button>
<button type="button" onclick="dismissBanner()" class="btn btn-link">
Not now
</button>
</div>
</g:if>
<asset:javascript src="passkey-register.js" />The DashboardController sets the showPasskeyBanner model attribute:
// grails-app/controllers/com/tucanoo/DashboardController.groovy
package com.tucanoo
import com.tucanoo.security.passkey.PasskeyService
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
@Secured(['ROLE_CRM_USER'])
class DashboardController {
SpringSecurityService springSecurityService
PasskeyService passkeyService
def index() {
def username = springSecurityService.principal.username
def showPasskeyBanner = !passkeyService.hasPasskeys(username)
[showPasskeyBanner: showPasskeyBanner]
}
}This is the entire adoption funnel: user logs in with password → sees banner → creates passkey → next login uses passkey. No emails, no forced migrations, no configuration pages. The best authentication upgrades are the ones that happen in the natural flow of using the application.
Security Considerations
CSRF Protection
If your Grails Spring Security configuration has CSRF protection enabled (which it should for session-based apps), you need to include the CSRF token in your passkey API calls. Add this to your JavaScript:
function getCsrfToken() {
return document.querySelector('meta[name="_csrf"]')?.content;
}
function getCsrfHeader() {
return document.querySelector('meta[name="_csrf_header"]')?.content;
}
// Then in fetch calls:
const headers = { 'Content-Type': 'application/json' };
const csrfToken = getCsrfToken();
const csrfHeader = getCsrfHeader();
if (csrfToken && csrfHeader) {
headers[csrfHeader] = csrfToken;
}And include the meta tags in your layout GSP:
<meta name="_csrf" content="${_csrf?.token}" />
<meta name="_csrf_header" content="${_csrf?.headerName}" />Signature Counter Validation
The signatureCount in PasskeyCredential is an anti-cloning measure specified in the WebAuthn standard. Each time an authenticator creates a signature, it increments an internal counter. Your server stores the counter value and checks that each new authentication presents a higher count. If the count goes backwards, it may indicate a cloned credential.
The Yubico library handles this check automatically in finishAssertion. Our service updates the stored count on every successful authentication. In practice, cloning a modern platform authenticator’s secure enclave is extraordinarily difficult, but the counter provides a defence-in-depth signal.
Rate Limiting
The /passkey/auth/options and /passkey/auth/complete endpoints are public and could be targeted for abuse. Consider adding rate limiting — a simple approach in Grails is a filter or interceptor that limits requests per IP per time window. The registration endpoints are protected by requiring authentication, so they are lower risk.
Production Deployment Considerations
HTTPS Is Required
WebAuthn will not work over plain HTTP in production. Browsers enforce a “secure context” requirement — the WebAuthn API is only available on pages served over HTTPS (or from localhost, which is the exception for development). When you deploy, your rpId must be a real domain and your origin must be https://yourdomain.com.
Updating rpId and origins
In production, update application.yml:
passkeys:
rpId: yourdomain.com
rpName: Your Application Name
origins:
- https://yourdomain.comThe rpId is bound to the domain at registration time — passkeys created with rpId: localhost will not work when you change to rpId: yourdomain.com. Plan your rpId from the start if you are registering real user credentials during development.
Database
The tutorial uses H2 in-memory storage for simplicity. In production, the PasskeyCredential domain class needs a real database. The credentialId and publicKeyCose fields are byte arrays — make sure your database column types support binary data (VARBINARY in MySQL/PostgreSQL, BLOB in Oracle). The GORM mapping in the domain class already specifies sqlType: 'varbinary(...)' for this purpose.
Testing the Flow
- Start the application:
./gradlew bootRun - Open
http://localhost:8080and sign in withuser/changeme - You are redirected to the dashboard. A banner appears prompting you to create a passkey.
- Click Create passkey. Your browser prompts for identity verification — this might be a fingerprint scan, Face ID, a Windows Hello PIN, or a security key tap, depending on your device and OS.

- Complete the verification. The banner disappears and you see a success message.
- Log out.
- On the login page, click Sign in with passkey. Your browser shows available passkeys.

- Select the passkey and verify. You are logged in without typing a password.

If something goes wrong, the most common issues are:
- “Origin not allowed” — your browser’s origin does not match the
passkeys.originslist inapplication.yml. Check scheme, host, and port exactly. - Browser says passkeys are unsupported — make sure you are using a current version of Chrome, Safari, Firefox, or Edge. Also check that your device has an available authenticator — this could be a fingerprint sensor, Face ID, Windows Hello (including PIN-only), or a connected hardware security key.
- No banner after login — the user already has a passkey registered. Check the
passkey_credentialtable in the H2 console (http://localhost:8080/h2-console). - Registration succeeds but authentication fails — likely a
rpIdmismatch between when the passkey was created and the current config. The rpId is baked into the credential at creation time.
What This Project Demonstrates
| Feature | Where to look |
|---|---|
| Password login with in-memory user | InMemoryUserDetailsService.groovy |
| Passkey registration API | PasskeyController.groovy — registerOptions, registerComplete |
| Passkey authentication API | PasskeyController.groovy — authOptions, authComplete |
| WebAuthn server orchestration | PasskeyService.groovy |
| Credential repository adapter | PasskeyCredentialRepository.groovy |
| One-time challenge cache | PasskeyChallengeCache.groovy |
| RelyingParty bean configuration | PasskeyConfiguration.groovy + resources.groovy |
| Login page passkey button + JS | auth.gsp |
| Post-login enrollment banner | DashboardController.groovy + dashboard/index.gsp |
| Persisted passkey credentials | PasskeyCredential.groovy (GORM domain class) |
Where To Go From Here
This implementation covers the core passkey flow. There are several enhancements you might consider for a production deployment:
Credential management UI — let users see their registered passkeys, rename them, and revoke ones they no longer use. The PasskeyCredential domain class already supports soft revocation via the revoked flag.
Multiple passkeys per user — the current implementation supports this out of the box (the maxCredentialsPerUser config is enforced at the application level). Users with multiple devices can register a passkey on each one.
Conditional UI / autofill — the WebAuthn spec now supports “conditional mediation” where passkeys appear in the browser’s autofill suggestions alongside saved passwords. This provides an even smoother UX where users do not need to click a separate button. Browser support is still evolving, but Chrome and Safari already support it.
Cross-device authentication — when a user does not have a passkey on their current device, modern browsers can use a QR code flow to authenticate via a phone. This works automatically with the current implementation — no server changes required.
Attestation verification — if you need to ensure credentials are created on specific hardware (regulated industries, government applications), the Yubico library supports attestation verification via the webauthn-server-attestation module. This adds complexity but provides hardware provenance guarantees.
Passkeys are not a future technology. They are supported today in every major browser, on every major operating system, and by every major credential manager. The gap is not in platform support — it is in application support. Every Grails application with a password login form is a candidate for this upgrade. The implementation is a few hundred lines of Groovy, a single domain class, and some JavaScript. The security improvement is transformational: from a shared secret that can be phished, leaked, and brute-forced, to a cryptographic proof that never leaves the user’s device.
The complete source code is at github.com/tucanoo/grails_passkey_tutorial. Clone it, run it, register a passkey, and see how it feels. Then add it to your own application.
