WebAuthn with Spring Security

Marco Amann
Digital Frontiers — Das Blog
15 min readJun 17, 2022

--

How to integrate WebAuthn in your project using spring security? We present a minimal sample that allows you to make use of WebAuthn with webauthn4j-spring-security. We build on the code of the webauthn4j project but have stripped away all things that are not necessarily required to get you running.

WebAuthn is a new authentication standard supported by most modern browsers allowing for public-key-based assertion of secret ownership. This enables two-factor authentication far beyond OTP.

For more in-depth information, I recommend reading the webauthn guide website and the spec.

What we will build

To achieve two-factor authentication, we will build a small Kotlin application using webauthn4j-spring-security and provide a 2-step login flow with username/password and a security token.

You can find the repo here.

To keep the sample as simple as possible, we stripped away all code and configuration that is not strictly needed for WebAuthn to work. You should expect a bit more work for a real project though. If you are interested in a more complete, but also more complex application, refer to the sample we built upon. The resulting application looks as follows:

Ugly but it works

Why WebAuthn is cool

Phishing just got way harder. This is a bold claim, so let’s dig into it. During a phishing attack, some attacker tries to trick you into entering sensitive information into a malicious website or website component. This sensitive information most often is your username and password, which the attacker wants to steal. They trick you into entering your credentials by presenting a more-or-less perfect clone of the original website you want to visit, but how did you get to the fake website in the first place?

Minor digression: Phishing

We can make the following distinction to categorize phishing attacks: Those that use the original domain and those that don’t.

The attacks from the first category are comparably rare since most websites use TLS nowadays and HSTS does prevent downgrade attacks to HTTP. However, websites that only server HTTP may be susceptible to attacks that simply manipulate your DNS or routing. This may happen by accident or malice, for example, if your country's government tries to censor parts of the internet or some shady guy in the corner of our coffeeshop plays with the wifi. If we assume our CAs to operate correctly (and no major security holes in the mimicked website), these attacks can only be carried out from unsecured connections, using no TLS.

The second category, containing attacks using domains different from the intended one, is more common. There are several ways to make links and domain names look real, e.g. IDN homograph attacks: Copy-paste the below links in your address bar and see yourself.

a) wikipediа.org
b) wikipedia.org

The first one actually is https://www.xn--wikipedi-86g.org/ but the Cyrillic letters look like the Latin ones. Studies have shown that much simpler means like swapping d and b in the URL often are sufficient. Would you have noticed the swap in Figure 1?

To understand the benefit WebAuthn provides here, let’s define two simple rules that, if followed, would save you from the attacks described above:

1) Do not enter credentials in unsecured connections.
2) Do not enter credentials in domains other than the one you registered with.

If you could get your granny to follow these rules, the internet would be a safer place.

These rules are of course not my own, they basically are the basics upon which the rules for accessing cookies are built. Rule 1 represents the Secure option and Rule 2 resembles the Domain option. If you don’t have them stolen, cookies are quite a secure way to authenticate your users, that’s why nearly every website out there uses them for exactly this purpose.

Although possible, it is quite unhandy to take your cookies from one device to another (and no, I don’t trust online cookie sync services), so we still need to sign in from time to time. So what to do against phished passwords?

A first step, OTP

Using an OTP, please either HOTP or TOTP and not some SMS thing, tries to increase the security of sign-in procedures by requiring a one-time password in addition to the typical username/password combination. These codes can be generated by apps like Google Authenticator or a free alternative like FreeOTP. The assumption is, that without a shared secret, you cannot guess the current code. This prevents an attacker from using the stolen credentials without the user’s cooperation. However, requiring OTP codes does not prevent you from loosing your credentials in a MITM scenario. This is due to the way the code comes from the secure environment (smartphone or special device) into the browser: the user has to type it. So if you are tricked into logging in into a malicious website with a faked URL, with the attacker as a man in the middle, you happily hand over your password and OTP. Still, this is far better than only using a username and password login.

Photo by Micah Williams on Unsplash

What WebAuthn adds

WebAuthn relies on authenticators, which are hardware or software devices doing some crypto stuff (we get to that later) and communicates with your browser. WebAuthn basically follows the rules I mentioned earlier in the context of cookies. Once registered with a website, an authenticator will only work for the website you registered it with. To rule out the whole complications with downgrades from HTTPS to HTTP, WebAuthn simply does not work with HTTP. More technical details can be found below.

So if you are tricked to goog1e.com to login instead of google.com, your authenticator will not work. Further, hardware authenticators have no overrides that you can be social engineered into using.

But aside from these fundamental improvements regarding the process of deciding whether to authenticate or not and the way authentication tokens are transported, there are also technical improvements we discuss later.

Why WebAuthn (currently) sucks

Since the above-mentioned benefits are quite convincing, why isn’t everyone and their grandma using WebAuthn? For me, the reason for the currently low adoption of WebAuthn is the same reason why not nearly as many sites used TLS back in 2012: It is not that easy and if done properly, costs money. Luckily, Let’s Encrypt solved this issue for TLS.

The first thing is, you need to have an authenticator. Normally this is a small, specialized device, and being a piece of hardware, it will cost a few dollars. An alternative to these roaming authenticators are platform authenticators directly integrated into your device. Examples of this are some fingerprint readers in android devices or some components in mac books. However, platform authenticators have two major downsides: You cannot change your computer without first registering the new device in your account (a thought experiment: how to securely authenticate on the new device before you can register it). Further, there is only a thin line between platform authenticators and remote-attestation bullshittery (link, link, link).

Compatibility is not as easy as with a simple password: Your client (most often the browser) needs to support the protocol (here WebAuthn) and the interfaces provided by your operating system, combined with the type of authenticator used. Just have a look at the image below to see the problems. (btw, who on earth would install Edge on macOS?)

Image from the fidoalliance

Another problem is the immaturity of the APIs and their implementations. Instead of bashing against any browsers, I want to illustrate this by describing a problem I faced when writing the sample application. Using the official webauthn4j-spring-security sample, I encountered the problem that something refused operation and Chrome printed the following error.

Googling the error message was of little help, so I resorted to dive into the Chromium sources to find out where the error might come from. Just have a look for yourself at the source, to see how many things can go wrong, resulting in this error. This complexity is not the fault of any developers but rather shows that we need a bit more patience until the developer experience is smoothed out.

There further is misbehaving software. Imagine a horde of non-technical users clogging your support hotlines because they got locked out of their accounts due to a seemingly unrelated driver update.

One great feature that WebAuthn provides is a password-less and user-name-less login process. You simply plug in your token, enter a pin or let it read your fingerprint and you are logged in. Although I understand how this works, this experience still gives off an alienating feeling. We probably are still too used to entering passwords.

Lastly, there are some use cases that make use of the hardware authenticators, that are borderline evil: Like requiring attestation as a way to implement some CAPTCHA based on trusted vendor keys. That’s not what the internet was made for.

Before we can write the sample application, we need to quickly summarize the relevant components of the WebAuthn protocol. I will mostly gloss over the details and link to relevant documentation.

WebAuthn Basics

The WebAuthn specs define several pieces of terminology that we need to be aware of when implementing the sample. The ones relevant for our project are:

  • Relaying Party: Our application
  • User: A person using our service
  • Client: The tool the user uses to communicate with our service, most likely a browser
  • Authenticator: Piece of hardware or software containing secret key material and providing cryptographic functions, e.g. a Yubikey
  • Assertion: A piece of data signed by the authenticator proving knowledge of a secret

The specs define several usage scenarios for authentication but we will focus on the following:

  • The user already has an account with a username and password registered and wants to increase account security by adding a security token as a second factor
  • After login with a username and password, the user is required to authenticate with the security token.

The following image depicts how the components work together

registration flow, as per the spec

Luckily we only have to care about the things above the dashed red line. Everything below is handled by the browser and authenticator, built in a way that we cannot interfere with it. That’s one important design feature of the protocol: The browser has strict control over what is allowed by the JavaScript and some things cannot be changed by (malicious) JavaScript. This isolation follows concepts similar to that of cookie APIs. An example: our JavaScript is forbidden to change the relying_party_id, because doing so would allow us to impersonate other websites, possibly allowing malicious code to generate valid assertions.

The login flow is depicted below. Again, we only have to care about the things above the red line.

authentication flow, as per the spec

Since these are two distinct protocol executions, we can handle them separately. The amount of messages that we need to send to the JS code from our app is manageable: A random challenge for registration and login, a bit of user info and information about our app (rp) upon registration. If we use the right library, all of this is handled by the library for us.

As responses, we have to handle a bit more, most of which is hidden in the clientDataJSON. Of course, we need to validate the signature but again, the library got us covered there.

So how does authentication work in detail? For our sample, it is sufficient to know, that the authenticator signs a bit of data every time the user wants to authenticate. Signing is done with a key located on the authenticator and we do not have any ways to extract it from there. On the server-side, the signature can be verified with the data saved upon registration. The last remaining interesting part is the data that is signed: The authenticator signs client data, containing metadata of the connection, including the origin, rp_id, challenge and authenticator data containing a signature counter (to make replay attacks with the same challenge impossible). Using this signature the RP can verify the user has access to the authenticator.

Sample Application

As promised, this blogpost is accompanied by a sample application (or vice versa) based on a stripped-down version of the sample coming with webauthn4j-spring-security.

A “real” application would probably implement authenticator registration as an optional process after user registration, in our sample application we implement this in one go. For authentication, we use the typical two-step login process of requesting a username and password and then authentication with the authenticator. I renounced any CSS, so be prepared for ugly design.

Preparations

To debug and to prevent our authenticator hardware from being polluted (and pressing that button every time), I advise you to use a virtual authenticator. This can be achieved in chrome with this nice plugin: virtual-authenticators-tab or in Firefox by enabling the security.webauth.webauthn_enable_softtoken key in about:config (and probably disable usbtoken). Since the chrome plugin allows for a bit more insights, I recommend that one.

Before you begin development, make sure your browser setup works as intended by testing registration at a sample site like webauthn.io.

Since WebAuthn refuses to work outside of a secure context, we need to expose our API in a way that is considered a secure context. This is most easily achieved by hosting the thing with a valid cert. For this, I use a subdomain of mine in conjunction with an NGINX provided with Let’s Encrypt certs, that forwards traffic through a reverse SSH tunnel.

You may or may not have success with using localhost or 127.0.0.1 but be aware that the origin has to match exactly. If localhost resolves to 127.0.0.1 and you registered the one and try to login with the other it won’t work.

Interactions

To allow registration and login with a WebAuthn token, we need to implement the following user interactions:

  • Register some user-account with username and password
  • Register an authenticator device with the user account. We do this with the previous step to keep things simple, you probably want to have a more elaborate user experience here.
  • Login with username and password but not be authenticated yet, rather the authenticator token is required as a second factor.
  • Upon authentication with the token, the user is completely logged in and can do whatever they need, in our case see a personalized greeting.

Although WebAuthn would allow for completely password-less (and user-name-less) registration and login flows, this sample deliberately only implements 2FA to keep it simple. If you are interested in the single step authentication, refer to the original sample of the library. Keep in mind, that this bears some further challenges like a mechanism to reset the password in case the authenticator is broken, lost or stolen, all without having a username set.

Setup

To get going, we need to have two dependencies configured, the webauhn library and something for cbor, since the clientDataJSON has to be decoded using cbor.

com.webauthn4j:webauthn4j-spring-security-core:0.7.1.RELEASE
com.fasterxml.jackson.dataformat:jackson-dataformat-cbor

Until someone writes an autoconfiguration, we need to provide a lot of beans, like a ChallengeRepository or a UserDetailsManager. We keep everything in memory, so this part is simply using the provided default implementations. Refer to the repo for that code.

Spring Web Security Configuration

Now to the more interesting parts, the Spring Web Security Configuration. We need to provide an AuthenticationProviderand a UserDetailsService, both coming from the default beans, so that we can let the library care about the details. In a real application, we probably want to couple this with our persistency or even some SSO system.

override fun configure(builder: AuthenticationManagerBuilder) {
builder.apply(WebAuthnAuthenticationProviderConfigurer(
webAuthnAuthenticatorService, webAuthnManager)
)
builder.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder)
}

Now we can configure the WebAuthn login and registration parts. Everything else is left to the defaults for now.

http.apply(WebAuthnLoginConfigurer.webAuthnLogin())
.defaultSuccessUrl("/", true)
.attestationOptionsEndpoint()
.rp().name("WebAuthn Sample").and()
.pubKeyCredParams(...ES256)
).and()
.assertionOptionsEndpoint()

And besides whitelisting the required endpoints, we finally can define how authorization should work.

http.authorizeRequests()
...
.mvcMatchers(HttpMethod.POST, "/signup").permitAll()
.anyRequest()
.access(
"@webAuthnSecurityExpression.isWebAuthnAuthenticated(authentication)")

That were all interesting parts of the configuration, some boring stuff was omitted, refer to the code for more details.

Handlers

There are only two interesting handlers here, one for redirecting the user in the login flow and one for signup.

The first one is quite short and redirects unauthenticated users that have not yet passed the first step to a “signin” form. Those that passed the first step but are still unauthenticated (we allow this only after authenticating with a token) are redirected to a template prompting for said token.

@GetMapping(value = ["/signin"])
fun signin(model: Model): String? {
val authentication = SecurityContextHolder.getContext().authentication
return if (authenticationTrustResolver.isAnonymous(authentication)) {
"signin"
} else {
model.addAttribute("name", authentication.name);
"signin-authenticator"
}
}

Note that we already show the user their name after the first step by including authentication.name. If this is a good idea can be discussed.

The signup handler is a bit more complex but mostly transfers information from the submitted form into a validation response and then into a representation of an authenticator.

val validationResponse: WebAuthnRegistrationRequestValidationResponse = try {
registrationRequestValidator.validate(
request,
userCreateForm.clientDataJSON,
userCreateForm.attestationObject,
userCreateForm.transports,
userCreateForm.clientExtensions
)
}

Then we need a user. This would normally store them in some database.

val user = User(userCreateForm.username, password, listOf())
userDetailsManager.createUser(user)

If the validationResponse was in fact valid, we can create an authenticator. Note that we can reject a validationResponse for a multitude of reasons, e.g. we could only allow tokens connected via USB.

val authenticator: WebAuthnAuthenticator = WebAuthnAuthenticatorImpl(
"authenticator",
user.username,
validationResponse.attestationObject....
... many more fields
)

and finally save the authenticator referencing the user.

webAuthnAuthenticatorManager.createAuthenticator(authenticator)

Now, whenever someone tries to log in, spring security handles the basic login flow. Then, after signin with the first factor (password), we provide the user with a list of allowed authenticator-ids, used in the field allowCredentials in the JS to query the browser APIs. Upon login with the second factor, webauthn4j spring security intercepts that request and looks up the saved authenticator for the current user and validates the signed challenge object. Luckily we do not have to write code for any of this.

Client Code

The client code basically juggles around a bunch of fields between the form components and credential data returned from the browser API.

The webautn4j library provides options endpoints to query available options for assertions and thereby authenticators. We hardcoded some of these options in the sample code to keep it clean but things like the challenge must be directly inserted in the credentials request.

return $.get('/webauthn/assertion/options', 
null, null, "json").then(options => {
let crOption = {
challenge: base64url.decodeBase64url(options.challenge),
allowCredentials: options.allowCredentials.map( credential => {
return {
type: credential.type,
id: base64url.decodeBase64url(credential.id)
}
}),
userVerification: 'discouraged',
};
return navigator.credentials.get({publicKey: crOption});
});

Now the login flow can be tested.

The login flow || You might want to zoom in

Let’s briefly discuss what you can see here:

The first screenshot was made directly after signup, you can still see the debug output in the console, stating that it has created some PublicKeyCredential. In the middle of the first screen, you can see that the virtual authenticator has the credential saved, with a sign count of 1. This is due to the requirements of the protocol to have the registration data already signed.

The second screen shows the login flow after entering the username and password. The user is greeted with their name and a button to request the authenticator from the browser. This looks absolutely stunning, who needs CSS anyways. Note that until now, nothing specific to WebAuthn happened.

The third screen shows the login flow after we clicked the button (and with a real security token, touched the little blinking thingy). In the terminal we can see that we found the requested PublicKeyCredential with the desired id. Note that the signage counter increased because we signed the assertion statement.

Conclusion

WebAuthn is cool but I would argue that using it is not quite as simple as it could be. The integration of webauthn4j with Spring Security is great, I only miss an autoconfiguration but given the plethora of available options that probably are different for each and every use case, this is no simple task. If you add WebAuthn to your app, be sure to read the spec, it is actually nicely written and that way you can make sure that you can make the most of your implementation.

Be aware of the implications that come with the used technology: If authenticators break or get lost, users need to reset their login flow somehow. Further, using hardware tokens makes sharing credentials almost impossible by design but some workflows require this albeit being discouraged.

Thanks for reading! If you have any questions, suggestions or critique regarding the topic, feel free to respond or contact me. You might be interested in the other posts published in the Digital Frontiers blog, announced on our Twitter account.

Cover image based on image by Free-Photos from Pixabay.

--

--