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
- Secure Session Storage: Use Redis or database for session storage
- Rate Limiting: Implement rate limiting for registration and authentication endpoints
- Backup Authenticators: Support multiple authenticators per user
- User Verification: Require user verification for sensitive operations
- 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.