If you have not read the previous blog post, I would highly recommend you read it first to understand the basics of passkeys and WebAuthn.
A passkey is a unique cryptographic key pair that allows you to access online services without using passwords. It is based on asymmetric public-key cryptography.
Passkeys are passwordless FIDO credentials implemented using WebAuthn.
Passkeys are superior to password + traditional OTP MFA in terms of security and usability and they are as secure and more convenient than password + FIDO MFA. Most importantly, you don’t have to remember anything.
Let's build a Spring Boot web app and secure it using passkeys with the help of Auth0 by Okta. You can find a sample app on GitHub if you just want to try passkeys.
Before you get started, you will need the following:
auth0 login command.Create a new Spring Boot application using the Spring Initializr. You can use the web version or the curl command below. Use the default for most of the options. For the dependencies, select web, and okta. For the build tool, select Gradle.
curl -G https://start.spring.io/starter.tgz \
-d dependencies=web,okta \
-d baseDir=passkey-demo \
| tar -xzvf -
web dependency provides Spring Web MVC with basic HTTP REST functionality.okta dependency provides the Okta Spring Boot Starter, which provides the required dependencies and configuration to add OIDC authentication to your application.Imports are omitted for brevity so make sure to import them using your IDE.
Open the created starter application in your favorite IDE. Add a simple web controller to the application. Create a new file src/main/java/com/example/demo/HomeController.java with the following content:
@RestController
class HomeController {
@GetMapping("/")
public String home(@AuthenticationPrincipal OidcUser user) {
return "Hello, " + user.getFullName() + "!";
}
}
This controller will handle requests to the / path.
If you run the application using
./gradlew bootRun, you will see a login page from the Okta Spring Boot starter instead of your home screen. This is OK, and you will be able to configure this soon. You can comment out theokta-spring-boot-starterdependency in thebuild.gradlefile if you want to run the application at this point.
Configure the application to use Auth0 as the Identity Provider (IdP). You can use the Auth0 CLI to create a new authorization server application. Run the following command to create a new application:
auth0 apps create \
--name "Spring Boot Passkeys" \
--description "Spring Boot Example" \
--type regular \
--callbacks http://localhost:8080/login/oauth2/code/okta \
--logout-urls http://localhost:8080 \
--reveal-secrets
--type option specifies that you use a regular web application.--callbacks option specifies the callback URL for the application.--logout-urls option specifies the logout URL for the application.--reveal-secrets option will display the client secret in the output.You can also use the auth0 apps update command to update the application with the callback and logout URLs.
Note down the Auth0 issuer (for example, https://dev-12345678.us.auth0.com/), CLIENT ID, and CLIENT SECRET from the output. You will use these values in the next step.
Configure the application by creating an application.properties file in the applications root folder with the following content:
# trailing `/` is important for issuer URI
okta.oauth2.issuer=https://<AUTH0_domain>/
okta.oauth2.client-id=<AUTH0_clientId>
okta.oauth2.client-secret=<AUTH0_clientSecret>
Add the application.properties file to the .gitignore file to avoid committing the secrets to the repository.
To run the application, execute the following command:
./gradlew bootRun
The application should start successfully. Navigate to http://localhost:8080 in your browser. You will be redirected to the Auth0 universal login page for authentication.
Click on the Sign up link to register a new user. Enter any email address and click Continue. You will now be prompted to register a passkey.
Create a passkey using your platform authenticator or roaming authenticator like YubiKey. Once you have registered a passkey, you should be redirected back to the application and see the welcome message.
Open a new incognito window and navigate to http://localhost:8080. You will be prompted to sign in using your passkey. Once you have signed in, you will see the welcome message.
Isn't that cool? You just implemented passkeys in your Spring Boot application with so little effort thanks to Auth0.
Though Web Authentication’s user experience is a client-side implementation using JavaScript, the backend or Relying party can be a Java server. Ideally using an IdP like Auth0 would be the best option since it takes care of all the heavy lifting for you. But if you want to implement it yourself and walk the harder path, you can use one of the below libraries.
Let's look at a simple Spring Boot application that uses passkeys for authentication without using an IdP. You can find the sample app on GitHub.
Start by cloning the application.
git clone https://github.com/deepu105/webauthn4j-spring-boot-passkeys-demo.git
cd webauthn4j-spring-boot-passkeys-demo
./gradlew bootRun
Visit http://localhost:8080/. You should see the below screen. Try registering a new user with passkeys and log in.
Let's look at some of the important parts of the application.
webauthn4j-spring-security-core dependency, in build.gradle file, provides the Spring Security integration for WebAuthn4j.src/main/java/com/example/demo/config/WebSecurityBeanConfig.java.InMemoryWebAuthnAuthenticatorManager is used to keep things simple but it means authenticator data is lost on application restart. For production use, it is better to implement the WebAuthnAuthenticatorManager interface and persist credential IDs for users.src/main/java/com/example/demo/config/WebSecurityConfig.java.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
// WebAuthn Login
http.apply(WebAuthnLoginConfigurer.webAuthnLogin())
.defaultSuccessUrl("/", true)
.failureHandler((request, response, exception) -> {
logger.error("Login error", exception);
response.sendRedirect("/login?error=Login failed: " + exception.getMessage());
})
.attestationOptionsEndpoint()
.rp()
.name("WebAuthn4J Passkeys Demo")
.and()
.pubKeyCredParams(
// supported algorithms for cryptography
new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256),
new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256)
)
.attestation(AttestationConveyancePreference.DIRECT)
.extensions()
.uvm(true)
.credProps(true)
.extensionProviders()
.and()
.assertionOptionsEndpoint()
.extensions()
.extensionProviders();
http.headers(headers -> {
// 'publickey-credentials-get *' allows getting WebAuthn credentials to all nested browsing contexts (iframes) regardless of their origin.
headers.permissionsPolicy(config -> config.policy("publickey-credentials-get *"));
// Disable "X-Frame-Options" to allow cross-origin iframe access
headers.frameOptions(Customizer.withDefaults()).disable();
});
// Authorization
http.authorizeHttpRequests(authz -> authz
.requestMatchers(HttpMethod.GET, "/login").permitAll()
.requestMatchers(HttpMethod.POST, "/signup").permitAll()
.anyRequest().access(getWebExpressionAuthorizationManager("@webAuthnSecurityExpression.isWebAuthnAuthenticated(authentication)"))
);
http.exceptionHandling(eh -> eh.accessDeniedHandler((request, response, accessDeniedException) -> {
logger.error("Access denied", accessDeniedException);
response.sendRedirect("/login");
}));
http.authenticationManager(authenticationManager);
// As WebAuthn has its own CSRF protection mechanism (challenge), CSRF token is disabled here
http.csrf(csrf -> {
csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
csrf.ignoringRequestMatchers("/webauthn/**");
});
return http.build();
}
...
}
The endpoints are configured in src/main/java/com/example/demo/web/WebAuthnSampleController.java. The / and /login endpoints are quite simple and self-explanatory. The /signup endpoint handles the WebAuthn registration request using WebAuthn4j. The request is first validated using WebAuthnRegistrationRequestValidator and then the authenticator is created using WebAuthnAuthenticatorManager.
@Controller
public class WebAuthnSampleController {
...
@PostMapping(value = "/signup")
public String create(HttpServletRequest request, @Valid @ModelAttribute("userForm") UserCreateForm userCreateForm, BindingResult result, Model model, RedirectAttributes redirectAttributes) {
try {
if (result.hasErrors()) {
model.addAttribute("errorMessage", "Your input needs correction.");
logger.error("User input validation failed.");
return VIEW_LOGIN;
}
WebAuthnRegistrationRequestValidationResponse registrationRequestValidationResponse;
try {
registrationRequestValidationResponse = registrationRequestValidator.validate(
request,
userCreateForm.getClientDataJSON(),
userCreateForm.getAttestationObject(),
userCreateForm.getTransports(),
userCreateForm.getClientExtensions()
);
} catch (WebAuthnException | WebAuthnAuthenticationException e) {
model.addAttribute("errorMessage", "Authenticator registration request validation failed. Please try again.");
logger.error("WebAuthn registration request validation failed.", e);
return VIEW_LOGIN;
}
var username = userCreateForm.getUsername();
var authenticator = new WebAuthnAuthenticatorImpl(
"authenticator",
username,
registrationRequestValidationResponse.getAttestationObject().getAuthenticatorData().getAttestedCredentialData(),
registrationRequestValidationResponse.getAttestationObject().getAttestationStatement(),
registrationRequestValidationResponse.getAttestationObject().getAuthenticatorData().getSignCount(),
registrationRequestValidationResponse.getTransports(),
registrationRequestValidationResponse.getRegistrationExtensionsClientOutputs(),
registrationRequestValidationResponse.getAttestationObject().getAuthenticatorData().getExtensions()
);
try {
webAuthnAuthenticatorManager.createAuthenticator(authenticator);
} catch (IllegalArgumentException ex) {
model.addAttribute("errorMessage", "Registration failed. The user may already be registered.");
logger.error("Registration failed.", ex);
return VIEW_LOGIN;
}
} catch (RuntimeException ex) {
model.addAttribute("errorMessage", "Registration failed by unexpected error.");
logger.error("Registration failed.", ex);
return VIEW_LOGIN;
}
model.addAttribute("successMessage", "User registration successful. Please login.");
return VIEW_LOGIN;
}
}
The file src/main/resources/templates/login.html handles login and sign-up. The login button will invoke the navigator.credentials.get() API and the register button will invoke the navigator.credentials.create() API. The buttons submit the corresponding forms with the input data in them. All inputs except the username field are hidden as their data will be set using JavaScript.
WebAuthn4j exposes /webauthn/attestation/options endpoint in the application to fetch the registration options. Some of the option parameters need to be decoded from base64URL. The base64url-arraybuffer library is used for this. The options are then passed to the navigator.credentials.create() API. The response from the API is then updated to the form fields and submitted to the /signup endpoint.
document.getElementById('signup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const userHandle = document.getElementById('userHandle').value;
const username = document.getElementById('username').value;
try {
const optionsRes = await fetch('/webauthn/attestation/options');
const options = await optionsRes.json();
const publicKey = {
...options,
challenge: base64url.decode(options.challenge, true),
user: {
id: base64url.decode(userHandle, true),
name: username,
displayName: username,
},
excludeCredentials: options.excludeCredentials.map((credential) => ({
...credential,
id: base64url.decode(credential.id, true),
})),
authenticatorSelection: {
requireResidentKey: true,
userVerification: 'discouraged',
},
};
const credential = await navigator.credentials.create({ publicKey });
document.getElementById('clientDataJSON').value = base64url.encode(credential.response.clientDataJSON);
document.getElementById('attestationObject').value = base64url.encode(credential.response.attestationObject);
document.getElementById('clientExtensions').value = JSON.stringify(credential.getClientExtensionResults());
document.getElementById('signup-form').submit();
} catch (error) {
console.error('Error:%s, Message:%s', error.name, error.message);
}
});
WebAuthn4j exposes /webauthn/assertion/options endpoint in the application to fetch the authentication options. Some of the option parameters need to be decoded from base64URL. The options are then passed to the navigator.credentials.get() API. The response from the API is then updated to the form fields and submitted to the /login endpoint.
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const optionsRes = await fetch('/webauthn/assertion/options');
const options = await optionsRes.json();
const publicKey = {
...options,
challenge: base64url.decode(options.challenge, true),
userVerification: 'preferred',
};
const credential = await navigator.credentials.get({ publicKey });
document.getElementById('credentialId').value = credential.id;
document.getElementById('loginClientDataJSON').value = base64url.encode(credential.response.clientDataJSON);
document.getElementById('authenticatorData').value = base64url.encode(credential.response.authenticatorData);
document.getElementById('signature').value = base64url.encode(credential.response.signature);
document.getElementById('loginClientExtensions').value = JSON.stringify(credential.getClientExtensionResults());
document.getElementById('login-form').submit();
} catch (error) {
console.error('Error:%s, Message:%s', error.name, error.message);
}
});
You have now learned:
Passkeys are the future of authentication. They are more secure and convenient than traditional passwords and OTPs. Though you could roll your own solution using WebAuthn4j, it is always better to use an IdP like Auth0 to handle the heavy lifting for you and take care of all the security best practices.
I hope that you found this article helpful. Here are some additional resources to learn more about WebAuthn and passkeys.
If you like this article, please leave a like or a comment.
You can follow me on Mastodon and LinkedIn.
For further actions, you may consider blocking this person and/or reporting abuse
We're a place where coders share, stay up-to-date and grow their careers.
Top comments (0)