developers

A Passwordless Future: Passkeys for Java Developers

Passkeys and WebAuthn for Java developers. Learn how to get started with passkeys for your Java and Spring Boot applications.

Dec 22, 202311 min read

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.

A Passwordless Future: Passkeys for Developers
A Passwordless Future: Passkeys for Developers
Passkeys and WebAuthn for developers. Learn how passkeys work and the benefits they provide.

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.

password vs passkeys

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
.

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:

@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 the
okta-spring-boot-starter
dependency in the
build.gradle
file if you want to run the application at this point.

Enable passkeys on your Auth0 tenant

  1. Log in to your Auth0 Dashboard and navigate to Authentication > Database > Username-Password-Authentication.
    1. If the second tab says Authentication Methods, your tenant supports passkeys, proceed to the next step.
    2. If the second tab says Password Policy, your tenant doesn't support passkeys, Create a new tenant and proceed to the next step.
  2. Navigate to Authentication > Authentication Profile and select Identifier First. Save your changes.
  3. 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:

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:

# 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:

./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.

Registration Screen

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.

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.

Sign up Screen

WebAuthn4j configuration

Let's look at some of the important parts of the application.

  • The
    webauthn4j-spring-security-core
    dependency, in
    build.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 the
    WebAuthnAuthenticatorManager
    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
    .
@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;
    }
}

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.

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);
    }
});

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.

In these blog posts, you can learn more about using the Okta Spring Boot starter.

You can also sign up for our newsletter to stay updated on everything Identity and Security.