FIDO Authentication in Sveltekit

Jack Reeve
Version 1
Published in
14 min readFeb 21, 2024
webauthn.io shield icon

What is FIDO?

Fast IDentity Online (FIDO) is password less authentication, including biometrics and 2FA. FIDO authentication has been popular ever since smartphones hit the market, but its adoption has pretty much stopped with phones.

It is a good idea to protect important accounts with 2FA of some kind. This way hackers need not only your password but also access to another piece of data, either a device that generates unique 2FA codes or a biometric, making it much less likely that they’ll gain unauthorised access.

Aside from security, biometric forms of authentication are usually just easier and more convenient (how handy is it that your phone just automatically unlocks when your finger is on the screen?). A lot of phone apps also have the ability to login using your fingerprint rather than a traditional user/pass combo, but this is not often seen on websites. Why?

Why can’t I use my fingerprint on websites?

Good question. The majority of websites even today still rely on the traditional username/password combination, maybe if you’re lucky they’ll have OAuth support and allow you to sign in with Google or Facebook, but good luck using your fingerprint.

In short, the reason for this is effort. For the longest time there just hasn’t been any sort of standard or specification for doing this on the web. No API in the browser to communicate with such devices means that websites would have to build their own solution from scratch.

Enter WebAuthn

WebAuthn is exactly that, an API in modern browsers that allows for communication between a website and a physical device for 2FA (such as YubiKey for desktops or the fingerprint reader in smartphones). It’s been around for almost half a decade now, yet adoption is still slow. Major sites like Google, Facebook, Microsoft have support, but we’re yet to see this really make a splash on smaller sites (see Dongle Auth for a list of sites supporting it).

Be the change you want to see in the world

Maybe Gandhi didn’t say this, but it’s still good advice. FIDO authentication doesn’t just have to be for the big dogs, it’s easy to implement in your own website and provides a much more convenient alternative to password based authentication. Let’s take a look.

A few words on security and privacy

It’s important to state here that sites won’t actually get your fingerprint or biometric data, these are just used to compute hashes that are then posted off. The domain name of the site and chosen identifier (username/email/etc…) are also used to compute this hash, so it would be very hard for a miscreant to abuse this data should it leak.

Not all devices are created equal and some have different use cases. Generally speaking there are 3 factors of authentication:

  1. Something you know (ie, password)
  2. Something you have (ie, a YubiKey 5)
  3. Something you are (ie, biometrics, fingerprint)

This blog is going to focus on the concept of alternatives forms of authentication and not being secure with multi factor authentication (MFA) as this is a whole subject on its own. Understanding that these exist is the first step should you wish to explore further (and you should!).

FIDO itself is NOT a more secure alternative to passwords. To illustrate this simply, I’m using a YubiKey (something you have) with my virtual marathon account. Should someone else steal my YubiKey they will theoretically have access to my account (if they also know the very weak PIN of 1234 I have set on it).

FIDO Authentication in Sveltekit

During some downtime earlier this year, I built a Virtual Marathon webapp (much like Conqueror Challenges) to motivate me to get outside and run. It needed to be a webapp because I wanted to be able to access it on desktop as well as mobile. I needed quick access and knew that if I had to mess around with typing in credentials I would give up (motivation is a fickle thing). So I decided to try giving FIDO a go with this app.

The webapp is built with Sveltekit (SSR) but the steps for FIDO are pretty universal, so Sveltekit isn’t necessary. Virtual Marathon provides 3 methods of authentication, traditional email/pass, OAuth with Google/Discord and FIDO. All three methods rely on a unique email address (identifier), only differing by their authentication data.

If the user is brand new, they’ll enter their email address and be prompted to setup their FIDO device (illustrated by a fingerprint icon for familiarity, but this can accept any FIDO2 certified device — such as a YubiKey). The process is effectively the same for logging in again, its up to the OS to display these dialogs, but usually the flow for using an existing key is shorter than initial registration.

Registration

GIF showing the registration flow on a desktop web browser

The user will then be prompted for a code to verify their email address. This is custom for Virtual Marathon as I wanted to verify that they own the email address used, but if you’re just registering usernames then you won’t need this.

Post FIDO registration Virtual Marathon asks for a verification code
Checking the code sent to our inbox

The user is then logged in under their new account.

Virtual Marathon new user screen

The register flow looks like this:

Diagram showing the FIDO registration flow
// +page.svelte (client side code)
async function handleSubmit() {
error = ''
// Submit the HTML form to the server. This only contains an email field
const result: ActionResult = await submit(htmlForm)

if (result.type === 'success') {
// Update the HTML form state (sveltekit specific)
await invalidateAll()
}

// Continue the FIDO flow depending on what the server sends back
fidoCallback({result})
}

We’re using the simplewebauthn npm package to handle the cryptography and formatting of things. Start the flow on the client side by POSTing off to the server with the user's email address

// +page.server.ts (server side code)
fido: async (event) => {
const form = await event.request.formData()
const email = form.get('email')?.toString()

if (!email || !validateEmail(email)) {
return fail(400, { message: 'Please enter a valid email address' })
}

// Search our DB for an existing auth - is the user trying to register or login?
const user = await prisma.user.findUnique({
where: { authId: email },
include: {
auths: true
}
})

// FIDO signatures are tied to a domain name. Fetch the one associated with this domain (ie, a localhost FIDO registration will NOT work on production)
const auths = user?.auths.filter(o => o.alias === FIDO_EXPECTED_ORIGIN) || []

// If we can't find any account data for this domain then we assume their intent is to register
if (!user || !auths.length) {
return fidoRegister(user, email)
}

// Else login
return fidOAuthenticate(user, email)
}

async function fidoRegister(user: (User & { auths: U2F[] }) | null, email: string) {
const opts = generateRegistrationOptions({
rpName: 'Virtual Marathon App',
rpID: FIDO_RPID, // Our domain name
userID: email,
userName: email,
attestationType: 'none',
excludeCredentials: user?.auths.map(o => {
return {
id: isoUint8Array.fromHex(o.credentialId),
type: 'public-key'
}
}) || [],
})

const challenge = opts.challenge
// Store this challenge in a DB so we can validate email verification attempts
await storeNewNoncePair(challenge, email)

return {
url: `${FIDO_EXPECTED_ORIGIN}/login/fido`,
intent: 'register',
opts
}
}

export async function storeNewNoncePair(nonce: string, data: string) {
return await prisma.noncePair.upsert({
where: { data: data },
update: { data, nonce },
create: { data, nonce }
})
}

Server validates that the email address is well formed and checks the DB to see if the user is already registered on this domain with FIDO data. Generate registration options by passing the name of our app, their email address and our domain to simplewebauthn'sgenerateRegistrationOptions .

*Note that rpID here matches our hosted domain and is part of the authentication object. So registrations used on one domain will not work for other domains (for example a staging environment will have different registrations than production)

Virtual Marathon stores the generated challenge from simplewebauth in a DB for processing later once the user has verified their email. This is not necessary if the registration flow is not broken up (ie, by verifying an email address). Then return to the client with an endpoint to hit for FIDO along with the intent to register and the object that was generated by simplewebauthn.

// +page.svelte (client side code)

// This function is called from handleSubmit() above once the server has responded
async function fidoCallback(json: { result: Record<string, any> }) {
const result = json.result
const { url, intent } = result.data

if (result.type === 'failure') {
error = result.data.message || 'No error specified'
return
}

if (intent === 'register') {
return await register(url, result, fidoEmail)
}

// Not relevant here as we're registering a new account
if (intent === 'login') {
return await login(url, result, fidoEmail)
}
}

async function register(url: string, result: Record<string, any>, user: string) {
try {
// This will prompt the OS to walk the user through either giving their fingerprint or touching a key etc...
const registration = await startRegistration(result.data.opts)

const res = await fetch(url, {
method: 'POST',
body: JSON.stringify({ ...registration, intent: 'register', username: user }),
headers: {
accept: 'application/json',
'Content-Type': 'application/json'
}
})
} catch (e: any) {
console.log(e)
}
}

Back to the client, fidoCallback is called when the server responds with the result from generateRegistrationOptions. We call startRegistration, the user will be prompted to provide their fingerprint or touch their security key by their OS. This is completely invisible to us and simplewebauthn will handle building an object out of the response. POST this back off to our server to continue.

// fido/+server.ts
async function fidoRegister(json: any, challenge: string) {
try {
const reg = await verifyRegistrationResponse({
response: json,
expectedChallenge: challenge,
expectedOrigin: FIDO_EXPECTED_ORIGIN,
expectedRPID: FIDO_RPID
})

if (!reg.registrationInfo) {
throw new Error(`FIDO verification failed`)
}

const { credentialID, credentialPublicKey } = reg.registrationInfo

await generateSecurityCode(json.username, isoUint8Array.toHex(credentialID), isoUint8Array.toHex(credentialPublicKey))

return responseJson({ success: true }, { status: 201 })
} catch (e: any) {
console.log(e)
return responseJson({ success: false, message: e.message }, { status: 400 })
}
}

async function generateSecurityCode(userAuthId: string, credentialId: string, publicKey: string) {
const code = crypto.randomUUID()
await prisma.emailVerify.create({ data: { code, userAuthId, credentialId, publicKey } })
await sendVerifyEmail(code, userAuthId)
return code
}

fidoRegister is called on the server when a POST is received from the client. The server then calls verifyRegistrationResponse to give us the final bits of data for registration. Registration is now technically complete and can be associated with the user, however, we have an email verify step to take care of first. This is as simple as generating a random unique code and storing it in the DB against the email address, The user can prove their have access to their specified email address if they can respond with this random code we sent to that address.

// verify/+page.server.ts (server side code)
export const load = (async (event) => {
const code = event.url.searchParams.get('code') || ''
const emailVerify = await prisma.emailVerify.findUnique({ where: { code } })

if (!emailVerify) {
return {
message: 'This email link has expired - please try signing in again!'
}
}

// We have no need for this anymore
await prisma.emailVerify.delete({ where: { code } })

const { userAuthId, credentialId, publicKey } = emailVerify
const u2f = await associateFidoWithUser(userAuthId, credentialId, publicKey)

await loginUser(event, u2f.user.authId)

return u2f
}) satisfies PageServerLoad;

async function associateFidoWithUser(userAuthId: string, credentialId: string, publicKey: string) {
let user = await prisma.user.findUnique({
where: { authId: userAuthId }
})

let isNewUser = false

if (!user) {
// We need to register this user first
const tempAvatar = await getRandomAvatar(userAuthId)
user = await registerNewUser(userAuthId, generateUsername(15, 0), tempAvatar, DEFAULT_COLOUR)
isNewUser = true
}

await prisma.u2F.create({
data: {
alias: FIDO_EXPECTED_ORIGIN,
credentialId: credentialId,
publicKey: publicKey,
userId: user.id
}
})

return { user, isNewUser }
}

async function registerNewUser(authId: string, name: string, avatar: string, accent: string) {
return await prisma.user.create({
data: {
authId,
name,
avatar,
accentColour: accent
}
})
}

async function loginUser(event: RequestEvent, authId: string) {
event.locals.user = await getUserFromAuthId(authId)
const session = await createSession(authId, event.locals.user.id)
event.cookies.set('session', session.id, { 'httpOnly': false, path: '/', secure: false })
}

The email sent out in sendVerifyEmail will contain a link to a /verify?code=X endpoint. Clicking on this link instructs the client to send a GET request off with unique code. This is used by the server to look up the previously stored registration data. We then associate the registration data with the new user, this is simply moving data around in the DB. At this point we now have a verified email address and a unique code (their FIDO details) that we can use to authenticate the user in the future. For all intents and purposes this is still an email/password pair except the password is guaranteed to be unique per user per domain and we don't force the user to enter this "password" in manually.

Login

Below is the login flow on a Windows web browser and an Android web browser. Since the OS itself handles gathering the FIDO device’s information, the flow can look very different between devices (but it will be consistent and familiar to the user!).

GIF showing the FIDO login flow on desktop web browser
GIF showing the FIDO login flow on an android web browser

The login flow is much simpler and the first half should look very similar to the register flow

Diagram showing the FIDO login flow

We’ve already seen some of the code responsible for logging in, but I’ll highlight it again here. When the email is sent off to the server, we check to see if the email address is known to us and that we have a FIDO registration held against their email and this domain. Assuming we do, we call off to simplewebauthn for authentication providing the existing FIDO registration data.

We store this new challenge data against the email address as the user will need to call a new endpoint to complete login and we need a way to resume their “session”. Return to the client with the challenge, the login url and their intent (to login).

// login/+page.server.ts

fido: async (event) => {
const form = await event.request.formData()
const intent = form.get('fido')?.toString()
const email = form.get('email')?.toString()

if (!email || !validateEmail(email)) {
return fail(400, { message: 'Please enter a valid email address' })
}

const user = await prisma.user.findUnique({
where: { authId: email },
include: {
auths: true
}
})

const auths = user?.auths.filter(o => o.alias === FIDO_EXPECTED_ORIGIN) || []

if (!user || !auths.length) {
return fidoRegister(user, email)
}

return fidOAuthenticate(user, email)
}
// login/+page.server.ts
async function fidOAuthenticate(user: (User & { auths: U2F[] }) | null, email: string) {
if (!user || !user.auths.length) {
return fail(404, { message: 'user not found or has no FIDO registration' })
}

const opts = generateAuthenticationOptions({
allowCredentials: user.auths.map(authenticator => {
return {
id: isoUint8Array.fromHex(authenticator.credentialId),
type: 'public-key',
}
}),
userVerification: 'preferred',
})

const challenge = opts.challenge
await storeNewNoncePair(challenge, email)

return {
challenge: challenge,
url: `${FIDO_EXPECTED_ORIGIN}/login/fido`,
intent: 'login',
opts
}
}

On the client, the same callback is used for both registration and login, the server told us that our intent is to login so we know to start the authentication process. Authentication is mostly handled by simplewebauthn will trigger the OS’s own flow and then we fire that data straight back to the server. Providing we get a successful response, we’re also storing the email in localStorage for easy access in future login attempts and then redirecting back to the home page.

// login/+page.svelte (client side)

async function fidoCallback(json: { result: Record<string, any> }) {
const result = json.result
const { url, intent } = result.data

if (result.type === 'failure') {
error = result.data.message || 'No error specified'
return
}

if (intent === 'register') {
return await register(url, result, fidoEmail)
}

+ if (intent === 'login') {
+ return await login(url, result, fidoEmail)
+ }
}
// login/+page.svelte (client side)
async function login(url: string, result: Record<string, any>, user: string) {
try {
const authentication = await startAuthentication(result.data.opts)
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify({ ...authentication, intent: 'login', username: user }),
headers: {
accept: 'application/json',
'Content-Type': 'application/json'
}
})
window.localStorage.setItem('email', fidoEmail)
window.location.replace('/')
} catch (e: any) {
console.log(e)
error = 'Login was cancelled'
}
}

The client will POST off to /login/fido and the endpoint shown below is registered to handle the event. We find the previously saved challenge data from DB and look up the user’s account information, then verify this with simplewebauthn. Providing the authentication data checks out, we can then be confident that the user is who they claim to be and log them in.

// login/fido/+server.ts (server side)
export const POST: RequestHandler = async (event) => {
const json = await event.request.json()
const challenge = await findNonceFromData(json.username)

if (json.intent === 'register') {
return await fidoRegister(json, challenge.nonce)
}

// Else if login
const user = await prisma.user.findUnique({ where: { authId: json.username }, include: { auths: true } })
const bodyCredIDBuffer = base64url.toBuffer(json.rawId);
const auth = user?.auths.find(o => o.alias === FIDO_EXPECTED_ORIGIN && isoUint8Array.areEqual(isoUint8Array.fromHex(o.credentialId), bodyCredIDBuffer))

if (!user || !auth) {
return responseJson({ success: false, message: 'User not found or not setup for FIDO on this device' }, { status: 404 })
}

try {
await verifyAuthenticationResponse({
response: json,
expectedChallenge: challenge.nonce,
expectedOrigin: FIDO_EXPECTED_ORIGIN,
expectedRPID: FIDO_RPID,
authenticator: {
credentialID: isoUint8Array.fromHex(auth.credentialId),
credentialPublicKey: isoUint8Array.fromHex(auth.publicKey)
}
})

await loginUser(event, json.username)
return responseJson({ success: true }, { status: 200 })
} catch (e) {
console.log(e)
return responseJson({ success: false, message: 'Login rejected' }, { status: 500 })
}
};

async function loginUser(event: RequestEvent, authId: string) {
event.locals.user = await getUserFromAuthId(authId)
const session = await createSession(authId, event.locals.user.id)
event.cookies.set('session', session.id, { 'httpOnly': false, path: '/', secure: false })
}

Conclusion

We’ve implemented a complete registration and login system here, as well as basic email verification. There are multiple uses for FIDO, it can be used as a 2nd factor for extra security and/or as an option for faster login. You could even forgo email verification and just use unique usernames to further speed up the flow, further reducing user sign up friction. Most people have access to a FIDO device nowadays, but not everyone has a discord or Microsoft account that they can use OAuth with (or want to).

We’ve not seen many smaller sites implement FIDO login as an option, and I think that’s a real shame. Hopefully I’ve convinced you how easy it is to set up. I chose to demo this in Sveltekit due to its lack of boilerplate code, so that all of the code in this tutorial is useful and doesn’t get in the way of understanding. I also haven’t touched this code in almost a year now (last commit was 11 months ago) and the flow is still simple to follow (providing you know the basics of Sveletkit routing).

The full code can be found on my GitLab (https://gitlab.com/jack.reevies/virtual-marathon-2023).

Feedback — Keen to hear form you!

This has been sat in my drafts for months. I really do think FIDO is cool and wanted to get this one out there. I recognise that this is a long one and will probably require some dedication to follow. Any feedback on how I can improve these sorts of tutorial blog posts is welcome. See you next time!

About the Author:
Jack Reeve is a Full Stack Software Developer at Version 1.

--

--