This project demonstrates how to combine Spring Boot 3 + Spring Security 6 (session-based) with WebAuthn / Passkeys, Thymeleaf templates and a relational database (PostgreSQL or H2).
- Quick start
- Project structure
- Authentication flows (step-by-step)
- Configuration
- Postgres vs H2
- Extending WebAuthn logic
# 1 - build & run
git clone <repo> passkey-demo && cd passkey-demo
./mvnw spring-boot:run
# 2 - open the app
open http://localhost:8080/register # macOS (or just paste into a browser)Default credentials: none – create a user first.
src/main/java/com/example/passkey
├─ config/ Spring Security & other configurations
├─ controller/ MVC controllers (Auth, Dashboard, Passkey)
├─ model/ JPA entities (User, PasskeyCredential)
├─ repository/ Spring-Data repositories
├─ service/ Business logic (UserService, PasskeyService*)
└─ PasskeyApplication.java
src/main/resources
├─ templates/ Thymeleaf HTML pages
├─ application.properties
└─ static/ (css/js if needed)
PasskeyService still contains TODOs where the real WebAuthn server-side validation should be implemented (using webauthn-server-core).
- /register – user fills e-mail + password.
UserService.register()hashes the password (BCrypt) and stores theUser.- /login – Spring Security form-login handles authentication, creates an HTTP session.
- /dashboard – secured page (requires
ROLE_USER).
- Logged-in user visits /passkey/register.
- Click Start Registration → JS calls
/passkey/register/optionsto obtain a minimalPublicKeyCredentialCreationOptionsJSON. - Browser shows the passkey prompt (Touch ID / Windows Hello / etc.).
- Upon success, JS posts
credentialId(+ stub fields) to/passkey/register/finish. PasskeyController.finishPasskeyRegistration()stores aPasskeyCredentialentity and refreshes the security context so the dashboard immediately shows "You already have a registered passkey."
- User opens /passkey/login and enters e-mail.
- JS fetches
/passkey/login/options?email=…→ server responds withPublicKeyCredentialRequestOptionscontaining the user's credential IDs. - Browser shows the passkey prompt.
- On success JS posts
credentialId+clientDataJsonto/passkey/login/finish. - Controller looks up the credential, creates a
UsernamePasswordAuthenticationToken, saves it to the session, and redirects to /dashboard.
Note: server-side signature verification is stubbed – see TODO markers in
PasskeyServicefor production-grade validation.
| Key | Default | Notes |
|---|---|---|
spring.thymeleaf.cache |
false | auto reload templates in dev |
spring.jpa.hibernate.ddl-auto |
update | auto-create / update schema |
spring.jpa.show-sql |
true | echo SQL in logs |
| CSRF | enabled | All forms include hidden CSRF token via Thymeleaf |
The project runs on H2 by default. To switch to Postgres:
- Install Postgres locally (no Docker required).
- Create a database and user, e.g.
psql -U postgres -c 'CREATE DATABASE passkey;' - Edit
application.properties– uncomment the Postgres section and comment the H2 lines:spring.datasource.url=jdbc:postgresql://localhost:5432/passkey spring.datasource.username=postgres spring.datasource.password=postgres spring.datasource.driver-class-name=org.postgresql.Driver # spring.datasource.url=jdbc:h2:mem:testdb # <- disabled
- Run the app – Hibernate will create the tables in Postgres.
Docker users can still run docker compose up -d db, but it's completely optional.
- Replace the stubbed methods in
PasskeyServicewith real calls toRelyingParty.startRegistration,finishRegistration,startAssertion,finishAssertionfrom webauthn-server-core. - Transfer the full
clientDataJSON,attestationObject,authenticatorData,signatureetc. from the browser to the backend (currently onlycredentialIdis sent for brevity). - Enforce origin / RP ID validation and signature counter tracking.
- Use a migration tool (Flyway/Liquibase) for schema evolution instead of
hibernate.ddl-auto.