Adding passkeys to a Remix App
Tags: remix, typescript, javascript, passkey
Published: 2024-03-04 10:45:53

This tutorial will show how you can add passkeys to your Remix app using [Hanko](https://www.hanko.io/features/hanko-passkey-api). We have a basic auth already setup, that uses `email` and `password` to login user. You can follow this awesome [guide](https://www.mattstobbs.com/remix-authentication/) by Matt Stobbs to see how we have added authentication.

Typically, to add passkeys to any app, you'd need two things:

* a backend to handle the authentication flow and store data about your user's passkeys
    
* a couple of functions on your frontend to bring up & handle the "Sign in with passkey" dialog
    

Let's dive in and see how you will do this.

## Get the API key and Tenant ID from Hanko

After creating your account on [Hanko](https://www.hanko.io/) and setting up the organization, go ahead and select 'Create new project,' and then choose 'Passkey infrastructure' and 'Create project'.


![choose project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wqvl4cpwodrohbbfu0gw.png)



Provide the Project name and URL and click on 'Create project'. Remember, if you're working on the project locally, the url will be of localhost, for example, `http://localhost:3000` .


![create project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dceigchqrkhjfzp49nu5.png)



Copy the 'Tenant ID', create an API key and copy the secret, and add it to your `.env` file.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5sg5cpmbnifc9kypi1ou.png)


```typescript
PASSKEYS_API_KEY="your-passkey-api-secret"
PASSKEYS_TENANT_ID="your-tenant-id"
```

## Install Hanko Passkey SDK

Install the [JavaScript SDK](https://docs.hanko.io/passkey-api/js-sdk) provided by Hanko and `webauth-json` package by GitHub.

```bash
npm add @teamhanko/passkeys-sdk @github/webauthn-json
```

## Allow your users to register passkey

Your users will need to add passkeys to their account. It’s up to you how and where you let them do this. We do it in the `app/routes/dashboard.tsx` route.

On your backend, you’ll have to call `tenant({ ... }).registration.initialize()` and `.registration.finalize()` to create and store a passkey for your user.

On your frontend, you’ll have to call `create()` from `@github/webauthn-json` with the object `.registration.initialize()` returned.

`create()` will return a `PublicKeyCredential` object, which you’ll have to pass to `.registration.finalize()`.

Here, we create two functions `startServerPasskeyRegistration` which uses `registration.initialize()` endpoint and `finishServerPasskeyRegistration` which uses `registration.finalize()` endpoint.

```typescript
import { tenant } from "@teamhanko/passkeys-sdk";
import { db } from "~/db";

const passkeyApi = tenant({
  apiKey: process.env.PASSKEYS_API_KEY!,
  tenantId: process.env.PASSKEYS_TENANT_ID!,
});

export async function startServerPasskeyRegistration(userID: string) {
  const user = db.users.find((user) => user.id === userID);

  const createOptions = await passkeyApi.registration.initialize({
    userId: user!.id,
    username: user!.email || "",
  });

  return createOptions;
}

export async function finishServerPasskeyRegistration(credential: any) {
  await passkeyApi.registration.finalize(credential);
}
```

Inside of `routes/api.passkeys.register.tsx` create a route action using the functions created above. This action will be responsible for registering the passkey for the user.

```typescript
import { json } from "@remix-run/node";
import { finishServerPasskeyRegistration, startServerPasskeyRegistration } from "~/utils/passkey.server";
import { getSession } from "~/utils/session.server";

 
export const action = async ({ request }: { request: Request }) => {

    const sessionData = await getSession(request);
    const userID = sessionData.get("userId");

    if (!userID) {
        return json({ message: "Unauthorized" }, 401);
    }
    const { start, finish, credential } = await request.json();

    try {
        if (start) {
            const createOptions = await startServerPasskeyRegistration(userID);
            return json({ createOptions });
        }
        if (finish) {
            await finishServerPasskeyRegistration(credential);
            return json({ message: "Registered Passkey" });
        }
    } catch (error) {
        return json(error, 500);
    }
};
```

  
Now, we're done with the backend setup. Next up, let's add a "Register Passkey" button. We'll use the endpoints we set up earlier to generate and save a passkey for the user.

```typescript
import { Form } from "@remix-   run/react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner"

import {
    create,
    type CredentialCreationOptionsJSON,
} from "@github/webauthn-json";
import { json, LoaderFunction, redirect } from "@remix-run/node";
import { getUserId } from "~/utils/session.server";

export const loader: LoaderFunction = async ({ request }) => {
    const userId = await getUserId(request);
    console.log(userId)
    if (!userId) return redirect("/login");
    return json({});
  }

export default function DashboardPage() {
    async function registerPasskey() {
        const createOptionsResponse = await fetch("/api/passkeys/register", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ start: true, finish: false, credential: null }),
        });

        const { createOptions } = await createOptionsResponse.json();

        // Open "register passkey" dialog
        const credential = await create(
            createOptions as CredentialCreationOptionsJSON,
        );

        const response = await fetch("/api/passkeys/register", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ start: false, finish: true, credential }),
        });

        if (response.ok) {
            toast.success("Registered passkey successfully!");
            return;
        }
    }
    return (
        <div className="p-4">
            <Form action="/logout" method="post">
                <Button type="submit" variant="link">
                    Logout
                </Button>
            </Form>
            <div>
                <Button
                    onClick={() => registerPasskey()}
                    className="flex justify-center items-center space-x-2"
                >
                    Register a new passkey
                </Button>
            </div>
        </div>
    );
}
```

If everything is set up correctly, now you should be able to register the passkey.

## Adding the SignIn with Passkey functionality

The process will be very similar to Passkey Registration. Inside of `utils/passkey.server.ts` add two more functions `startServerPasskeyLogin()` and `finishServerPasskeyLogin()` which use the `login.initialize()` and `login.finalize()` endpoints respectively.

```typescript
export async function startServerPasskeyLogin() {
  const options = await passkeyApi.login.initialize();
  return options;
}

export async function finishServerPasskeyLogin(options: any) {
  const response = await passkeyApi.login.finalize(options);
  return response;
}
```

Now, similar to passkey registration create a route action `routes/api.passkeys.login.tsx` to log in the user. Here, after the login process is finished, the `finishServerPasskeyLogin` returns JWT, which we decode using `jose` to get the User ID and create a new session for the user.

```typescript
import { json } from "@remix-run/node";
import { getUserID } from "~/utils/get-user-id.server";
import { finishServerPasskeyLogin, startServerPasskeyLogin } from "~/utils/passkey.server";
import { createUserSession } from "~/utils/session.server";

export const action = async ({ request }: { request: Request }) => {
    const { start, finish, options } = await request.json();

    try {
        if (start) {
            const loginOptions = await startServerPasskeyLogin();
            return json({ loginOptions });
        }
        if (finish) {
            const jwtToken = await finishServerPasskeyLogin(options);
            const userID = await getUserID(jwtToken?.token ?? '');

            return createUserSession({
                request,
                userId: userID ?? '',
            });
        }
    } catch (error) {
        if(error instanceof Response){
            return error;
        }
        return json(error, 500);
    }
};
```

Here's the function to extract the UserID from `jose`.

```typescript
// app/utils/get-user-id.server.ts

import * as jose from "jose";

export async function getUserID(token: string) {
  const payload = jose.decodeJwt(token ?? "");

  const userID = payload.sub;
  return userID;
}
```

Alright, now we just need to create a 'SignIn with Passkey' button and use the endpoints we created above. After the response is successful, we navigate the user to `/dashboard` route.

```typescript
import { ActionFunction } from "@remix-run/node";
import { Form, useNavigate } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { createUserSession, verifyLogin } from "~/utils/session.server";
import { get } from "@github/webauthn-json";

export const action: ActionFunction = async ({ request }) => {
    const formData = await request.formData();
    const email = formData.get("email") as string;
    const password = formData.get("password") as string;

    const user = await verifyLogin(email, password);

    if (!user) {
        return new Response("Invalid email or password", {
            status: 401,
            headers: {
                "Content-Type": "text/plain",
            },
        });
    }

    return createUserSession({
        request,
        userId: user.id,
    });
}

export default function LoginPage() {
    const navigate = useNavigate();
    
    // here we add the 
    async function signInWithPasskey() {
        const createOptionsResponse = await fetch("/api/passkeys/login", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ start: true, finish: false, credential: null }),
        });

        const { loginOptions } = await createOptionsResponse.json();

        // Open "login passkey" dialog
        const options = await get(
            loginOptions as any,
        );

        const response = await fetch("/api/passkeys/login", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ start: false, finish: true, options }),
        });

        if (response.ok) {
            console.log("user logged in with passkey")
            navigate("/dashboard")
            return;
        }
    }
    return (
        <div>
            <div className="w-screen h-screen flex items-center justify-center">
                <Card className="w-full max-w-lg">
                    <CardHeader>
                        <CardTitle>Sign In</CardTitle>
                        <CardDescription className="">Choose your preferred sign in method</CardDescription>
                    </CardHeader>
                    <CardContent>
                        <div className="flex flex-col">
                            <Form method="POST">
                                <div className="flex flex-col gap-y-2">
                                    <Label>Email</Label>
                                    <Input
                                        id="email"
                                        required
                                        name="email"
                                        type="email"
                                        autoComplete="email"
                                    />
                                    <Label>Password</Label>
                                    <Input
                                        id="password"
                                        name="password"
                                        type="password"
                                        autoComplete="current-password"
                                    />
                                </div>
                                <Button type="submit" className="mt-4 w-full">Sign in with Email</Button>
                            </Form>
                            <div className="relative mt-4">
                                <div className="absolute inset-0 flex items-center">
                                    <span className="w-full border-t" />
                                </div>
                                <div className="relative flex justify-center text-xs uppercase">
                                    <span className="bg-background px-2 text-muted-foreground">
                                        Or continue with
                                    </span>
                                </div>
                            </div>
                            <Button className="mt-4 w-full" onClick={() => signInWithPasskey()}>Passkey</Button>
                        </div>
                    </CardContent>
                </Card>
            </div>
        </div>
    );
}
```

That's all. You have successfully integrated Passkey login to your remix app, making the authentication process much easier and smoother for your user 🚀

Check out the github repo, if you wanna play around.

{% embed https://github.com/teamhanko/remix-passkeys %}