google-site-verification: google61fe8ba583a51912.html
Passwordless Future: Implementing FIDO2 WebAuthn Authentication in Java


Article

The shift towards passwordless authentication is accelerating, and FIDO2/WebAuthn (Web Authentication) is at the forefront of this revolution. As a W3C standard, WebAuthn enables strong, phishing-resistant authentication using biometrics, security keys, and platform authenticators. For Java applications—from enterprise systems to consumer web apps—integrating FIDO2 provides superior security while eliminating password-related risks and support costs.

What is FIDO2/WebAuthn?

FIDO2 is a suite of specifications that enables passwordless authentication across the web. The key components are:

  • WebAuthn: W3C standard for web application integration
  • CTAP2: Client to Authenticator Protocol for communication with authenticators
  • Authenticators: Hardware security keys, biometric sensors, or platform authenticators

Key Benefits for Java Applications:

  • Phishing Resistance: Credentials are bound to the origin
  • No Passwords: Eliminate password database breaches
  • User Experience: Simple biometric or PIN authentication
  • Strong Security: Public key cryptography instead of shared secrets

FIDO2 Authentication Flow

Registration: User → Java App → Generate Registration Options → Browser → Authenticator → Store Credential Authentication: User → Java App → Generate Authentication Options → Browser → Authenticator → Verify Signature

Java WebAuthn Implementation

Approach 1: Using the Yubico WebAuthn Library

The Yubico java-webauthn-server library is the most popular Java implementation.

1. Add Dependencies:

<!-- pom.xml --> <dependencies> <dependency> <groupId>com.yubico</groupId> <artifactId>webauthn-server-core</artifactId> <version>2.0.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies>

2. WebAuthn Server Configuration:

@Configuration public class WebAuthnConfig { @Value("${app.origin:https://localhost:8443}") private String origin; @Bean public RelyingParty relyingParty(CredentialRepository credentialRepository) { RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder() .id("localhost") // Your domain without scheme .name("My Java Application") .build(); return RelyingParty.builder() .identity(rpIdentity) .credentialRepository(credentialRepository) .origins(Collections.singleton(origin)) .build(); } @Bean public ObjectMapper objectMapper() { return new ObjectMapper() .registerModule(new Jdk8Module()) .registerModule(new JSR310Module()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } }

3. Credential Repository Interface:

public interface CredentialRepository { // Registration Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(String username); Optional<ByteArray> getUserHandleForUsername(String username); Optional<String> getUsernameForUserHandle(ByteArray userHandle); Optional<RegisteredCredential> lookup(ByteArray credentialId, ByteArray userHandle); Set<RegisteredCredential> lookupAll(ByteArray credentialId); // Storage boolean addCredential(RegisteredCredential credential, String username, String credentialNickname, Long signatureCount); // User management ByteArray generateUserHandle(); boolean userExists(String username); }

4. Database-Backed Credential Repository:

@Repository @Transactional public class DatabaseCredentialRepository implements CredentialRepository { @PersistenceContext private EntityManager entityManager; @Override public Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(String username) { String jpql = "SELECT c.credentialId FROM WebAuthnCredential c " + "WHERE c.username = :username AND c.enabled = true"; List<ByteArray> credentialIds = entityManager .createQuery(jpql, ByteArray.class) .setParameter("username", username) .getResultList(); return credentialIds.stream() .map(credentialId -> PublicKeyCredentialDescriptor.builder() .id(credentialId) .type(PublicKeyCredentialType.PUBLIC_KEY) .build()) .collect(Collectors.toSet()); } @Override public Optional<RegisteredCredential> lookup(ByteArray credentialId, ByteArray userHandle) { String jpql = "SELECT c FROM WebAuthnCredential c " + "WHERE c.credentialId = :credentialId " + "AND c.userHandle = :userHandle " + "AND c.enabled = true"; try { WebAuthnCredential credential = entityManager .createQuery(jpql, WebAuthnCredential.class) .setParameter("credentialId", credentialId) .setParameter("userHandle", userHandle) .getSingleResult(); return Optional.of(RegisteredCredential.builder() .credentialId(credential.getCredentialId()) .userHandle(credential.getUserHandle()) .publicKeyCose(credential.getPublicKeyCose()) .signatureCount(credential.getSignatureCount()) .build()); } catch (NoResultException e) { return Optional.empty(); } } @Override public boolean addCredential(RegisteredCredential credential, String username, String credentialNickname, Long signatureCount) { try { WebAuthnCredential entity = new WebAuthnCredential(); entity.setCredentialId(credential.getCredentialId()); entity.setUserHandle(credential.getUserHandle()); entity.setPublicKeyCose(credential.getPublicKeyCose()); entity.setUsername(username); entity.setCredentialNickname(credentialNickname); entity.setSignatureCount(signatureCount); entity.setEnabled(true); entity.setCreatedAt(Instant.now()); entityManager.persist(entity); return true; } catch (Exception e) { return false; } } @Override public ByteArray generateUserHandle() { byte[] userHandle = new byte[64]; new SecureRandom().nextBytes(userHandle); return new ByteArray(userHandle); } // Implement other required methods... }

5. JPA Entity for Credential Storage:

@Entity @Table(name = "webauthn_credentials") public class WebAuthnCredential { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "credential_id", length = 1024) @Convert(converter = ByteArrayConverter.class) private ByteArray credentialId; @Column(name = "user_handle", length = 1024) @Convert(converter = ByteArrayConverter.class) private ByteArray userHandle; @Column(name = "public_key_cose", length = 2048) @Convert(converter = ByteArrayConverter.class) private ByteArray publicKeyCose; @Column(name = "username") private String username; @Column(name = "credential_nickname") private String credentialNickname; @Column(name = "signature_count") private Long signatureCount; @Column(name = "enabled") private Boolean enabled; @Column(name = "created_at") private Instant createdAt; // Constructors, getters, and setters } @Converter public class ByteArrayConverter implements AttributeConverter<ByteArray, byte[]> { @Override public byte[] convertToDatabaseColumn(ByteArray attribute) { return attribute != null ? attribute.getBytes() : null; } @Override public ByteArray convertToEntityAttribute(byte[] dbData) { return dbData != null ? new ByteArray(dbData) : null; } }

WebAuthn Registration Flow

1. Registration Controller:

@RestController @RequestMapping("/api/webauthn") public class WebAuthnRegistrationController { private final RelyingParty relyingParty; private final CredentialRepository credentialRepository; private final ObjectMapper objectMapper; public WebAuthnRegistrationController(RelyingParty relyingParty, CredentialRepository credentialRepository, ObjectMapper objectMapper) { this.relyingParty = relyingParty; this.credentialRepository = credentialRepository; this.objectMapper = objectMapper; } @PostMapping("/registration/start") public ResponseEntity<?> startRegistration(@RequestBody StartRegistrationRequest request) { try { // Generate user handle if new user ByteArray userHandle = credentialRepository.getUserHandleForUsername(request.getUsername()) .orElseGet(() -> credentialRepository.generateUserHandle()); // Start registration ceremony PublicKeyCredentialCreationOptions options = relyingParty.startRegistration( StartRegistrationOptions.builder() .user(UserIdentity.builder() .name(request.getUsername()) .displayName(request.getDisplayName()) .id(userHandle) .build()) .build()); // Store temporary session data RegistrationSession session = new RegistrationSession( request.getUsername(), request.getDisplayName(), options, Instant.now().plus(Duration.ofMinutes(5)) ); // In production, store this in a secure session store sessionStorage.save(session); return ResponseEntity.ok(objectMapper.writeValueAsString(options)); } catch (Exception e) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Registration failed: " + e.getMessage())); } } @PostMapping("/registration/finish") public ResponseEntity<?> finishRegistration(@RequestBody String responseJson) { try { // Parse the response RegistrationResponse response = objectMapper.readValue( responseJson, RegistrationResponse.class); // Retrieve session data RegistrationSession session = sessionStorage.get(response.getRequestId()); if (session == null || session.isExpired()) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Session expired")); } // Complete registration RegistrationResult result = relyingParty.finishRegistration( FinishRegistrationOptions.builder() .request(session.getOptions()) .response(response) .build()); // Store the credential RegisteredCredential credential = RegisteredCredential.builder() .credentialId(result.getKeyId().getId()) .userHandle(session.getOptions().getUser().getId()) .publicKeyCose(result.getPublicKeyCose()) .signatureCount(result.getSignatureCount()) .build(); boolean saved = credentialRepository.addCredential( credential, session.getUsername(), "Primary Key", // nickname result.getSignatureCount() ); if (!saved) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Failed to save credential")); } // Clear session sessionStorage.remove(response.getRequestId()); return ResponseEntity.ok(Collections.singletonMap("status", "success")); } catch (Exception e) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Registration failed: " + e.getMessage())); } } }

WebAuthn Authentication Flow

2. Authentication Controller:

@RestController @RequestMapping("/api/webauthn") public class WebAuthnAuthenticationController { private final RelyingParty relyingParty; private final CredentialRepository credentialRepository; private final ObjectMapper objectMapper; public WebAuthnAuthenticationController(RelyingParty relyingParty, CredentialRepository credentialRepository, ObjectMapper objectMapper) { this.relyingParty = relyingParty; this.credentialRepository = credentialRepository; this.objectMapper = objectMapper; } @PostMapping("/authentication/start") public ResponseEntity<?> startAuthentication(@RequestBody StartAuthenticationRequest request) { try { String username = request.getUsername(); // Get allowed credentials for this user Set<PublicKeyCredentialDescriptor> allowedCredentials = credentialRepository.getCredentialIdsForUsername(username); // Start authentication ceremony AssertionRequest options = relyingParty.startAssertion( StartAssertionOptions.builder() .username(Optional.of(username)) .allowedCredentials(allowedCredentials) .build()); // Store temporary session data AuthenticationSession session = new AuthenticationSession( username, options, Instant.now().plus(Duration.ofMinutes(5)) ); sessionStorage.save(session); return ResponseEntity.ok(objectMapper.writeValueAsString(options)); } catch (Exception e) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Authentication failed: " + e.getMessage())); } } @PostMapping("/authentication/finish") public ResponseEntity<?> finishAuthentication(@RequestBody String responseJson, HttpServletRequest httpRequest) { try { // Parse the response AssertionResponse response = objectMapper.readValue( responseJson, AssertionResponse.class); // Retrieve session data AuthenticationSession session = sessionStorage.get(response.getRequestId()); if (session == null || session.isExpired()) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Session expired")); } // Complete authentication AssertionResult result = relyingParty.finishAssertion( FinishAssertionOptions.builder() .request(session.getOptions()) .response(response) .build()); if (!result.isSuccess()) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Authentication failed")); } // Update signature count updateSignatureCount(result.getCredential().getCredentialId(), result.getSignatureCount()); // Create session for authenticated user String authToken = createAuthToken(session.getUsername()); // Clear authentication session sessionStorage.remove(response.getRequestId()); return ResponseEntity.ok(Collections.singletonMap("token", authToken)); } catch (Exception e) { return ResponseEntity.badRequest().body( Collections.singletonMap("error", "Authentication failed: " + e.getMessage())); } } private void updateSignatureCount(ByteArray credentialId, long newSignatureCount) { // Update the signature count in the database // This helps detect cloned authenticators } private String createAuthToken(String username) { // Create a JWT or session token for the authenticated user return "auth-token-" + username; } }

Frontend Integration

JavaScript Client Code:

class WebAuthnClient { async startRegistration(username, displayName) { const response = await fetch('/api/webauthn/registration/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName }) }); const options = await response.json(); options.challenge = this.base64ToArrayBuffer(options.challenge); options.user.id = this.base64ToArrayBuffer(options.user.id); if (options.excludeCredentials) { options.excludeCredentials = options.excludeCredentials.map(cred => ({ ...cred, id: this.base64ToArrayBuffer(cred.id) })); } return options; } async finishRegistration(response) { const responseJson = { requestId: response.requestId, credential: { id: response.id, type: response.type, response: { clientDataJSON: this.arrayBufferToBase64(response.response.clientDataJSON), attestationObject: this.arrayBufferToBase64(response.response.attestationObject) } } }; const result = await fetch('/api/webauthn/registration/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(responseJson) }); return result.json(); } async startAuthentication(username) { const response = await fetch('/api/webauthn/authentication/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }); const options = await response.json(); options.challenge = this.base64ToArrayBuffer(options.challenge); if (options.allowCredentials) { options.allowCredentials = options.allowCredentials.map(cred => ({ ...cred, id: this.base64ToArrayBuffer(cred.id) })); } return options; } async finishAuthentication(response) { const responseJson = { requestId: response.requestId, credential: { id: response.id, type: response.type, response: { clientDataJSON: this.arrayBufferToBase64(response.response.clientDataJSON), authenticatorData: this.arrayBufferToBase64(response.response.authenticatorData), signature: this.arrayBufferToBase64(response.response.signature), userHandle: response.response.userHandle ? this.arrayBufferToBase64(response.response.userHandle) : null } } }; const result = await fetch('/api/webauthn/authentication/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(responseJson) }); return result.json(); } // Utility methods for base64/ArrayBuffer conversion base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } }

Spring Security Integration

WebAuthn Authentication Provider:

@Component public class WebAuthnAuthenticationProvider implements AuthenticationProvider { private final CredentialRepository credentialRepository; private final JwtTokenService jwtTokenService; public WebAuthnAuthenticationProvider(CredentialRepository credentialRepository, JwtTokenService jwtTokenService) { this.credentialRepository = credentialRepository; this.jwtTokenService = jwtTokenService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { WebAuthnAuthenticationToken authToken = (WebAuthnAuthenticationToken) authentication; try { // Verify the WebAuthn assertion AssertionResult result = verifyAssertion(authToken.getAssertionResponse()); if (result.isSuccess()) { // Get user details String username = credentialRepository .getUsernameForUserHandle(result.getUserHandle()) .orElseThrow(() -> new BadCredentialsException("User not found")); // Create authenticated token User user = new User(username, "", getAuthorities(username)); WebAuthnAuthenticationToken authenticated = new WebAuthnAuthenticationToken(user, authToken.getCredentials(), user.getAuthorities()); authenticated.setAuthenticated(true); // Generate JWT token String jwt = jwtTokenService.generateToken(username); authenticated.setDetails(jwt); return authenticated; } } catch (Exception e) { throw new BadCredentialsException("WebAuthn authentication failed", e); } throw new BadCredentialsException("WebAuthn authentication failed"); } @Override public boolean supports(Class<?> authentication) { return WebAuthnAuthenticationToken.class.isAssignableFrom(authentication); } private List<GrantedAuthority> getAuthorities(String username) { // Load user authorities from your user service return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")); } }

Best Practices for Production

  1. Secure Session Storage: Use Redis or database for session storage
  2. Rate Limiting: Implement rate limiting for registration and authentication endpoints
  3. Backup Authenticators: Support multiple authenticators per user
  4. User Verification: Require user verification for sensitive operations
  5. Audit Logging: Log all authentication events
@Aspect @Component public class WebAuthnAuditAspect { private static final Logger logger = LoggerFactory.getLogger(WebAuthnAuditAspect.class); @AfterReturning("execution(* com.example..*Controller.*Registration*(..))") public void auditRegistration(JoinPoint joinPoint) { Object[] args = joinPoint.getArgs(); if (args.length > 0 && args[0] instanceof StartRegistrationRequest) { StartRegistrationRequest request = (StartRegistrationRequest) args[0]; logger.info("WebAuthn registration started for user: {}", request.getUsername()); } } @AfterReturning("execution(* com.example..*Controller.*Authentication*(..))") public void auditAuthentication(JoinPoint joinPoint) { Object[] args = joinPoint.getArgs(); if (args.length > 0 && args[0] instanceof StartAuthenticationRequest) { StartAuthenticationRequest request = (StartAuthenticationRequest) args[0]; logger.info("WebAuthn authentication started for user: {}", request.getUsername()); } } }

Conclusion

Implementing FIDO2 WebAuthn in Java applications provides a robust, passwordless authentication solution that significantly enhances security while improving user experience. By leveraging libraries like Yubico's java-webauthn-server and integrating with Spring Security, Java developers can build modern authentication systems that are resistant to phishing and eliminate password-related risks.

The combination of strong cryptographic security, user-friendly biometric authentication, and standards-based interoperability makes WebAuthn an essential component of modern Java application security. As the industry moves toward a passwordless future, adopting FIDO2 WebAuthn positions Java applications at the forefront of authentication security and user experience.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper