diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java
new file mode 100644
index 0000000000..c35cf5b81c
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHints.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.aot.hint;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
+
+/**
+ *
+ * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a
+ * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence.
+ *
+ * @author Max Batischev
+ * @since 6.5
+ */
+class PublicKeyCredentialUserEntityRuntimeHints implements RuntimeHintsRegistrar {
+
+ @Override
+ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+ hints.resources().registerPattern("org/springframework/security/user-entities-schema.sql");
+ }
+
+}
diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java
new file mode 100644
index 0000000000..bfeaafb0e8
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepository.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.management;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.PreparedStatementSetter;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.SqlParameterValue;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.util.Assert;
+
+/**
+ * A JDBC implementation of an {@link PublicKeyCredentialUserEntityRepository} that uses a
+ * {@link JdbcOperations} for {@link PublicKeyCredentialUserEntity} persistence.
+ *
+ * NOTE: This {@code PublicKeyCredentialUserEntityRepository} depends on the table
+ * definition described in
+ * "classpath:org/springframework/security/user-entities-schema.sql" and therefore MUST be
+ * defined in the database schema.
+ *
+ * @author Max Batischev
+ * @since 6.5
+ * @see PublicKeyCredentialUserEntityRepository
+ * @see PublicKeyCredentialUserEntity
+ * @see JdbcOperations
+ * @see RowMapper
+ */
+public final class JdbcPublicKeyCredentialUserEntityRepository implements PublicKeyCredentialUserEntityRepository {
+
+ private RowMapper userEntityRowMapper = new UserEntityRecordRowMapper();
+
+ private Function> userEntityParametersMapper = new UserEntityParametersMapper();
+
+ private final JdbcOperations jdbcOperations;
+
+ private static final String TABLE_NAME = "user_entities";
+
+ // @formatter:off
+ private static final String COLUMN_NAMES = "id, "
+ + "name, "
+ + "display_name ";
+ // @formatter:on
+
+ // @formatter:off
+ private static final String SAVE_USER_SQL = "INSERT INTO " + TABLE_NAME
+ + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?)";
+ // @formatter:on
+
+ private static final String ID_FILTER = "id = ? ";
+
+ private static final String USER_NAME_FILTER = "name = ? ";
+
+ // @formatter:off
+ private static final String FIND_USER_BY_ID_SQL = "SELECT " + COLUMN_NAMES
+ + " FROM " + TABLE_NAME
+ + " WHERE " + ID_FILTER;
+ // @formatter:on
+
+ // @formatter:off
+ private static final String FIND_USER_BY_NAME_SQL = "SELECT " + COLUMN_NAMES
+ + " FROM " + TABLE_NAME
+ + " WHERE " + USER_NAME_FILTER;
+ // @formatter:on
+
+ private static final String DELETE_USER_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER;
+
+ // @formatter:off
+ private static final String UPDATE_USER_SQL = "UPDATE " + TABLE_NAME
+ + " SET name = ?, display_name = ? "
+ + " WHERE " + ID_FILTER;
+ // @formatter:on
+
+ /**
+ * Constructs a {@code JdbcPublicKeyCredentialUserEntityRepository} using the provided
+ * parameters.
+ * @param jdbcOperations the JDBC operations
+ */
+ public JdbcPublicKeyCredentialUserEntityRepository(JdbcOperations jdbcOperations) {
+ Assert.notNull(jdbcOperations, "jdbcOperations cannot be null");
+ this.jdbcOperations = jdbcOperations;
+ }
+
+ @Override
+ public PublicKeyCredentialUserEntity findById(Bytes id) {
+ Assert.notNull(id, "id cannot be null");
+ List result = this.jdbcOperations.query(FIND_USER_BY_ID_SQL,
+ this.userEntityRowMapper, id.toBase64UrlString());
+ return !result.isEmpty() ? result.get(0) : null;
+ }
+
+ @Override
+ public PublicKeyCredentialUserEntity findByUsername(String username) {
+ Assert.hasText(username, "name cannot be null or empty");
+ List result = this.jdbcOperations.query(FIND_USER_BY_NAME_SQL,
+ this.userEntityRowMapper, username);
+ return !result.isEmpty() ? result.get(0) : null;
+ }
+
+ @Override
+ public void save(PublicKeyCredentialUserEntity userEntity) {
+ Assert.notNull(userEntity, "userEntity cannot be null");
+ boolean existsUserEntity = null != this.findById(userEntity.getId());
+ if (existsUserEntity) {
+ updateUserEntity(userEntity);
+ }
+ else {
+ try {
+ insertUserEntity(userEntity);
+ }
+ catch (DuplicateKeyException ex) {
+ updateUserEntity(userEntity);
+ }
+ }
+ }
+
+ private void insertUserEntity(PublicKeyCredentialUserEntity userEntity) {
+ List parameters = this.userEntityParametersMapper.apply(userEntity);
+ PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
+ this.jdbcOperations.update(SAVE_USER_SQL, pss);
+ }
+
+ private void updateUserEntity(PublicKeyCredentialUserEntity userEntity) {
+ List parameters = this.userEntityParametersMapper.apply(userEntity);
+ SqlParameterValue userEntityId = parameters.remove(0);
+ parameters.add(userEntityId);
+ PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
+ this.jdbcOperations.update(UPDATE_USER_SQL, pss);
+ }
+
+ @Override
+ public void delete(Bytes id) {
+ Assert.notNull(id, "id cannot be null");
+ SqlParameterValue[] parameters = new SqlParameterValue[] {
+ new SqlParameterValue(Types.VARCHAR, id.toBase64UrlString()), };
+ PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
+ this.jdbcOperations.update(DELETE_USER_SQL, pss);
+ }
+
+ private static class UserEntityParametersMapper
+ implements Function> {
+
+ @Override
+ public List apply(PublicKeyCredentialUserEntity userEntity) {
+ List parameters = new ArrayList<>();
+
+ parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getId().toBase64UrlString()));
+ parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getName()));
+ parameters.add(new SqlParameterValue(Types.VARCHAR, userEntity.getDisplayName()));
+
+ return parameters;
+ }
+
+ }
+
+ private static class UserEntityRecordRowMapper implements RowMapper {
+
+ @Override
+ public PublicKeyCredentialUserEntity mapRow(ResultSet rs, int rowNum) throws SQLException {
+ Bytes id = Bytes.fromBase64(new String(rs.getString("id").getBytes()));
+ String name = rs.getString("name");
+ String displayName = rs.getString("display_name");
+
+ return ImmutablePublicKeyCredentialUserEntity.builder().id(id).name(name).displayName(displayName).build();
+ }
+
+ }
+
+}
diff --git a/web/src/main/resources/META-INF/spring/aot.factories b/web/src/main/resources/META-INF/spring/aot.factories
index 4c3991233f..2a3c8ad768 100644
--- a/web/src/main/resources/META-INF/spring/aot.factories
+++ b/web/src/main/resources/META-INF/spring/aot.factories
@@ -1,3 +1,4 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints,\
-org.springframework.security.web.aot.hint.UserCredentialRuntimeHints
+org.springframework.security.web.aot.hint.UserCredentialRuntimeHints,\
+org.springframework.security.web.aot.hint.PublicKeyCredentialUserEntityRuntimeHints
diff --git a/web/src/main/resources/org/springframework/security/user-entities-schema.sql b/web/src/main/resources/org/springframework/security/user-entities-schema.sql
new file mode 100644
index 0000000000..ec66c66519
--- /dev/null
+++ b/web/src/main/resources/org/springframework/security/user-entities-schema.sql
@@ -0,0 +1,7 @@
+create table user_entities
+(
+ id varchar(1000) not null,
+ name varchar(100) not null,
+ display_name varchar(200),
+ primary key (id)
+);
diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java
new file mode 100644
index 0000000000..4909a64303
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/aot/hint/PublicKeyCredentialUserEntityRuntimeHintsTests.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.aot.hint;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.core.io.support.SpringFactoriesLoader;
+import org.springframework.util.ClassUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link PublicKeyCredentialUserEntityRuntimeHints}
+ *
+ * @author Max Batischev
+ */
+public class PublicKeyCredentialUserEntityRuntimeHintsTests {
+
+ private final RuntimeHints hints = new RuntimeHints();
+
+ @BeforeEach
+ void setup() {
+ SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories")
+ .load(RuntimeHintsRegistrar.class)
+ .forEach((registrar) -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader()));
+ }
+
+ @ParameterizedTest
+ @MethodSource("getUserEntitiesSqlFiles")
+ void userEntitiesSqlFilesHasHints(String schemaFile) {
+ assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints);
+ }
+
+ private static Stream getUserEntitiesSqlFiles() {
+ return Stream.of("org/springframework/security/user-entities-schema.sql");
+ }
+
+}
diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java
new file mode 100644
index 0000000000..503108ac4e
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcPublicKeyCredentialUserEntityRepositoryTests.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.management;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link JdbcPublicKeyCredentialUserEntityRepository}
+ *
+ * @author Max Batischev
+ */
+public class JdbcPublicKeyCredentialUserEntityRepositoryTests {
+
+ private EmbeddedDatabase db;
+
+ private JdbcPublicKeyCredentialUserEntityRepository repository;
+
+ private static final String USER_ENTITIES_SQL_RESOURCE = "org/springframework/security/user-entities-schema.sql";
+
+ @BeforeEach
+ void setUp() {
+ this.db = createDb();
+ JdbcOperations jdbcOperations = new JdbcTemplate(this.db);
+ this.repository = new JdbcPublicKeyCredentialUserEntityRepository(jdbcOperations);
+ }
+
+ @AfterEach
+ void tearDown() {
+ this.db.shutdown();
+ }
+
+ private static EmbeddedDatabase createDb() {
+ // @formatter:off
+ return new EmbeddedDatabaseBuilder()
+ .generateUniqueName(true)
+ .setType(EmbeddedDatabaseType.HSQL)
+ .setScriptEncoding("UTF-8")
+ .addScript(USER_ENTITIES_SQL_RESOURCE)
+ .build();
+ // @formatter:on
+ }
+
+ @Test
+ void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new JdbcPublicKeyCredentialUserEntityRepository(null))
+ .withMessage("jdbcOperations cannot be null");
+ // @formatter:on
+ }
+
+ @Test
+ void saveWhenUserEntityIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> this.repository.save(null))
+ .withMessage("userEntity cannot be null");
+ // @formatter:on
+ }
+
+ @Test
+ void findByUserEntityIdWheIdIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> this.repository.findById(null))
+ .withMessage("id cannot be null");
+ // @formatter:on
+ }
+
+ @Test
+ void findByUserNameWheUserNameIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> this.repository.findByUsername(null))
+ .withMessage("name cannot be null or empty");
+ // @formatter:on
+ }
+
+ @Test
+ void saveUserEntityWhenSaveThenReturnsSaved() {
+ PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
+
+ this.repository.save(userEntity);
+
+ PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId());
+ assertThat(savedUserEntity).isNotNull();
+ assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId());
+ assertThat(savedUserEntity.getDisplayName()).isEqualTo(userEntity.getDisplayName());
+ assertThat(savedUserEntity.getName()).isEqualTo(userEntity.getName());
+ }
+
+ @Test
+ void saveUserEntityWhenUserEntityExistsThenUpdates() {
+ PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
+ this.repository.save(userEntity);
+
+ this.repository.save(testUserEntity(userEntity.getId()));
+
+ PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId());
+ assertThat(savedUserEntity).isNotNull();
+ assertThat(savedUserEntity.getId()).isEqualTo(userEntity.getId());
+ assertThat(savedUserEntity.getDisplayName()).isEqualTo("user2");
+ assertThat(savedUserEntity.getName()).isEqualTo("user2");
+ }
+
+ @Test
+ void findUserEntityByUserNameWhenUserEntityExistsThenReturnsSaved() {
+ PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
+ this.repository.save(userEntity);
+
+ PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName());
+
+ assertThat(savedUserEntity).isNotNull();
+ }
+
+ @Test
+ void deleteUserEntityWhenRecordExistThenSuccess() {
+ PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
+ this.repository.save(userEntity);
+
+ this.repository.delete(userEntity.getId());
+
+ PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId());
+ assertThat(savedUserEntity).isNull();
+ }
+
+ @Test
+ void findUserEntityByIdWhenUserEntityDoesNotExistThenReturnsNull() {
+ PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
+
+ PublicKeyCredentialUserEntity savedUserEntity = this.repository.findById(userEntity.getId());
+ assertThat(savedUserEntity).isNull();
+ }
+
+ @Test
+ void findUserEntityByUserNameWhenUserEntityDoesNotExistThenReturnsEmpty() {
+ PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
+
+ PublicKeyCredentialUserEntity savedUserEntity = this.repository.findByUsername(userEntity.getName());
+ assertThat(savedUserEntity).isNull();
+ }
+
+ private PublicKeyCredentialUserEntity testUserEntity(Bytes id) {
+ // @formatter:off
+ return ImmutablePublicKeyCredentialUserEntity.builder()
+ .name("user2")
+ .id(id)
+ .displayName("user2")
+ .build();
+ // @formatter:on
+ }
+
+}