TOC
Summary
- A Passwordless Future: Passkeys for Developers
- Create a Spring Boot application
- Add a web controller
- Enable passkeys on your Auth0 tenant
- Configure OIDC Authentication with Auth0
- Configure the Spring Boot application
- Run the application
- Clone and run the application
- WebAuthn4j configuration
- Client-side configuration
A Passwordless Future: Passkeys for Java Developers
This is part of my "Passkeys" series
- A Passwordless Future: Passkeys for Developers
- A Passwordless Future: Passkeys for Java Developers
Originally published at auth0.com
This blog post is a continuation of my previous blog post on passkeys. In this post, you will learn how to implement passkeys using Auth0 and the WebAuthn4j library on your Java applications.
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.
Passkeys
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.
Why Passkeys?
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 See Passkeys in Action with Auth0
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:
- Java 17 or higher. You can use SDKMAN! to install Java if you don’t have it already.
- A free Auth0 account. Sign up if you don’t have one already.
- The Auth0 CLI. Install the CLI if you don’t have it and log in to your Auth0 account using the
auth0 login
command.
Create a Spring Boot application
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
.
1
2
3
4
curl -G https://start.spring.io/starter.tgz \
-d dependencies=web,okta \
-d baseDir=passkey-demo \
| tar -xzvf -
- The
web
dependency provides Spring Web MVC with basic HTTP REST functionality. - The
okta
dependency provides the Okta Spring Boot Starter, which provides the required dependencies and configuration to add OIDC authentication to your application.
Add a web controller
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:
1
2
3
4
5
6
7
@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-starter
dependency in thebuild.gradle
file if you want to run the application at this point.
Enable passkeys on your Auth0 tenant
- Log in to your Auth0 Dashboard and navigate to Authentication > Database > Username-Password-Authentication.
- If the second tab says Authentication Methods, your tenant supports passkeys, proceed to the next step.
- If the second tab says Password Policy, your tenant doesn’t support passkeys, Create a new tenant and proceed to the next step.
- Navigate to Authentication > Authentication Profile and select Identifier First. Save your changes.
- Navigate to Authentication > Database > Username-Password-Authentication and select the Authentication Methods tab and enable Passkey.
Configure OIDC Authentication with Auth0
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:
1
2
3
4
5
6
7
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
- The
--type
option specifies that you use a regular web application. - The
--callbacks
option specifies the callback URL for the application. - The
--logout-urls
option specifies the logout URL for the application. - The
--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 Spring Boot application
Configure the application by creating an application.properties
file in the applications root folder with the following content:
1
2
3
4
# 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.
Run the application
To run the application, execute the following command:
1
./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.
WebAuthn for Java
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.
- WebAuthn4j: A 100% FIDO2 conformant library with support for all attestation formats and validation. It is used by Keycloak and Spring Security.
- java-webauthn-server: A library from Yubico that supports many attestation format. But it is not 100% FIDO2 conformant.
WebAuthn4j with Spring Security in Action
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.
Clone and run the application
Start by cloning the application.
1
2
3
4
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.
WebAuthn4j configuration
Let’s look at some of the important parts of the application.
- The
webauthn4j-spring-security-core
dependency, inbuild.gradle
file, provides the Spring Security integration for WebAuthn4j. - The required beans for WebAuthn4j are configured in
src/main/java/com/example/demo/config/WebSecurityBeanConfig.java
. - The
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 theWebAuthnAuthenticatorManager
interface and persist credential IDs for users. - WebAuthn4j is configured using the standard Spring Security filter chain in
src/main/java/com/example/demo/config/WebSecurityConfig.java
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@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
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@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;
}
}
Client-side configuration
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
}
});
Conclusion
You have now learned:
- How to implement passkeys using an IdP like Auth0.
- You also learned how to configure the application to use Auth0 as the Identity Provider and how to configure Auth0 for passkey support.
- Roll your own passkey solution using WebAuthn4j and Spring Security.
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.
Resources
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.
Post 2 of 2 in series "Passkeys".