This tutorial will show how you can add passkeys to your Remix app using Hanko Passkey API. We have a basic auth already set up that uses email
and password
to login users. You can follow this awesome guide by Matt Stobbs to see how we have implemented 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 a passkey" dialog
Let's dive in and see how you will do that.
Install Hanko Passkey SDK
Install the JavaScript SDK provided by Hanko and webauth-json
package by GitHub.
npm add @teamhanko/passkeys-sdk @github/webauthn-json
Allow your users to register a 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.
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.
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.
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>
);
}
Get credentials from Hanko Cloud
Now, to make it work we're missing one crucial step: getting the Tenant ID
and API key secret
from Hanko Cloud. For that, navigate over to Hanko, create an account, and set up your organization. Navigate to 'Create new project', select 'Passkey Infrastructure', and provide your project name and URL.
Note: It's recommended to create separate projects for your development and production environments. This way, you can use your frontend localhost URL for the development project and your production URL for the live project, ensuring a smooth transition between environments without the need to modify the 'App URL' later.
Obtain your Tenant ID
and create an API key, then add the Tenant ID
and API key secret
to your backend's '.env' file.
Now that you have your secrets added to your backend's .env
file, you should be all set to register a passkey. Go ahead and give it a try!
Adding the Sign in with a 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.
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.
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
.
// 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 'Sign in with a passkey' button and use the endpoints we created above. After the response is successful, we navigate the user to /dashboard
route.
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()}>Sign in with a passkey</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
That's all. You have successfully integrated a passkey login to your remix app, making the authentication process much easier and smoother for your users 🚀
Check out the GitHub repo here.