Going Passwordless With py_webauthn
Would you like to drop passwords completely from your users’ sign-in experience? With the right settings, the WebAuthn API can easily give you strong assurance that a user is who they say they are without them ever having to enter a password.
Let’s dive into how you can use Duo’s py_webauthn library to enable passwordless user authentication in your Python server.
WebAuthn and Multi-Factor Authentication
Multi-factor authentication is built on the basic tenet, “Something you know, something you have, something you are: pick two.”
It isn’t obvious just by looking at the spec, but WebAuthn can be configured for three discrete “modes” that satisfy different combinations of these factors: two-factor authentication, passwordless authentication, and “usernameless” authentication.
Here, we’ll focus on achieving passwordless authentication by capturing the following factors:
- Physical interaction with a hardware authenticator, like a YubiKey Security Key, for the possession factor (“something you have”)
- The user’s subsequent entry of a PIN or biometric scan for the knowledge factor (“something you know”) or inherence factor (“something you are”), respectively.
Together, these two factors quickly and sufficiently authenticate the user’s identity in a single interaction.
Setting up WebAuthn for passwordless authentication is easy if you leverage one of the many open-source libraries available for most popular programming languages. Let’s see how we can accomplish this with Duo’s own Python-based py_webauthn library.
Setup
py_webauthn can be installed from PyPI with the following command:
pip install webauthn
An example server is available for you to follow along with on Github.
Registration
First, we’ll need to make it possible for new users to create an account. To start, present the user with a form for them to enter their username:
When the user submits the signup form use py_webauthn’s generate_registration_options()
method on the server to prepare options for registering a new authenticator:
Note: the options below all eventually map to navigator.credentials.create()
arguments as defined as PublicKeyCredentialCreationOptions
in the WebAuthn spec.
from webauthn import generate_registration_options, options_to_json
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
)
logged_in_user_id = "1234567890"
options = generate_registration_options(
# A name for your "Relying Party" server
rp_name="Example Server",
# Your domain on which WebAuthn is being used
rp_id="exampleserver.com",
# An assigned random identifier;
# never anything user-identifying like an email address
user_id=logged_in_user_id,
# A user-visible hint of which account this credential belongs to
# An email address is fine here
user_name="exampleUser",
# Require the user to verify their identity to the authenticator
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED,
),
)
# Remember the challenge for later, you'll need it in the next step
current_challenges[logged_in_user_id] = options.challenge
options_json = options_to_json(options)
Send options_json
back to the front end (as JSON via REST, as data attributes in rendered HTML; however is appropriate for your architecture).
On the front end, some of the Base64URL-encoded values in options_json
will need to be decoded to ArrayBuffer
s before the options can all be passed into WebAuthn’s navigator.credentials.create()
. Decoding these values is made difficult by the lack of standard functionality to do so in JavaScript; you'll almost certainly want to use a third-party library like @simplewebauthn/browser to handle this for you. It’s compatible with py_webauthn and will call navigator.credentials.create with the correctly formatted options for you. It will also take care of encoding ArrayBuffer
s in the response to Base64URL for ease of sending back to the server:
const { startRegistration } = SimpleWebAuthnBrowser;
// Get options
const resp = await fetch("/generate-registration-options");
const opts = await resp.json();
// Start WebAuthn Registration
let regResp;
try {
regResp = await startRegistration(opts);
} catch (err) {
throw new Error(err);
}
// Send response to server
const verificationResp = await fetch(
"/verify-registration-response",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(regResp),
}
);
Once the front end sends back the return value from navigator.credentials.create()
, pass it into verify_registration_response()
:
from webauthn import verify_registration_response
from webauthn.helpers.structs import RegistrationCredential
# Retrieve the challenge we saved earlier
current_challenge = current_challenges[logged_in_user_id]
try:
credential = RegistrationCredential.parse_raw(request_body)
verification = verify_registration_response(
credential=credential,
expected_challenge=current_challenge,
expected_rp_id="exampleserver.com",
expected_origin="https://exampleserver.com",
require_user_verification=True,
)
except Exception as err:
return {"verified": False, "msg": str(err), "status": 400}
# ...snip...
return {"verified": True}
What’s Going on Here?
When we set user_verification=UserVerificationRequirement.REQUIRED
earlier, while generating options for registration, we’re telling the authenticator that it must verify the user’s identity. This most commonly refers to the user performing one of the following with their authenticator:
- Entering a configured PIN
- Scanning a registered biometric, as via a modality like Touch ID or Face ID
Note: The user’s actual identity is never revealed to the website; rather, what we get back from the authenticator via WebAuthn is a thumbs-up/thumbs-down that the user successfully performed one of the actions above.
Note: It is possible that you require user verification to take place but the browser is unable to orchestrate this for some reason (for example, a PIN hasn’t been set, biometrics haven’t been registered, or the authenticator doesn’t support either of these use cases). In this case it’s more common for the browser to throw an error on the front end and not return a value to pass back to the server. It should be uncommon to require user verification and then receive a response indicating that no such verification occurred.
To reiterate, by requiring user verification you ensure that the user provides two factors of authentication with a single authenticator interaction: possession factor (“something you have”) and knowledge factor (“something you know”) or inherence factor (“something you are”). It’s either of the last two that completely replaces the factor provided by passwords with alternatives that are much more difficult for attackers to gain access to! This is the primary way in which “passwordless” is made possible with WebAuthn.
Going back to the code, if verify_registration_response()
doesn't error out, then congratulations, the user has successfully completed passwordless registration
Authentication
Just as we did with registration, let’s now set up both authentication-related py_webauthn methods to require user verification as well. First, ask the user for their username with a basic login form:
When the user submits their username, set the user_verification
argument to UserVerificationRequirement.REQUIRED
when calling generate_authentication_options()
:
from webauthn import generate_authentication_options, options_to_json
from webauthn.helpers.structs import (
UserVerificationRequirement,
)
options = generate_authentication_options(
rp_id=rp_id,
user_verification=UserVerificationRequirement.REQUIRED,
)
options_json = options_to_json(options)
As before, send options_json
to your front end code to eventually pass into navigator.credentials.get()
. Send the response back to your server for verification, this time setting require_user_verification=True
when calling verify_authentication_response()
:
from webauthn import verify_authentication_response
from webauthn.helpers.structs import AuthenticationCredential
try:
verification = verify_authentication_response(
credential=AuthenticationCredential.parse_raw(request_body),
expected_challenge=current_challenge,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=user_credential.public_key,
credential_current_sign_count=user_credential.sign_count,
require_user_verification=True,
)
except Exception as err:
return {"verified": False, "msg": str(err), "status": 400}
# ...snip...
return {"verified": True}
What’s Going on Here?
When we generate options for authentication we're telling the library to set the userVerification
option to "required"
so that the user must also verify their identity to the authenticator when they interact with it.
And just as we did when verifying the registration response, we’re also setting up the library to fail verification if it determines that user verification didn’t occur.
Next Steps
And there you have it: passwordless authentication powered by py_webauthn! For more tips on the best ways to implement WebAuthn-based passwordless authentication, take a closer look at the sample project and its many comments. The future of authentication is here, right now! So what are you waiting for? Go passwordless!