Blog

Creating a passkey-enabled web app with Turnkey

Developer
·
March 20, 2025

In this post we’ll get into how you can set up a new NextJS project and use Turnkey to generate passkeys in the browser, and set them up as a user’s authentication method for wallets.

From here you could build a dApp and use them as embedded wallets, provide ways for users to fund a wallet or perform swaps and other transactions, and more.

Let’s get into it.

Prerequisites

This tutorial assumes you have some familiarity with web development and NextJS in particular, as well as NodeJS/NPM installed.

Create a new NextJS Project

Create a new NextJS project with the following command:

npm create next@latest tk-passkeys-web-app

When prompted, you can accept the default options by hitting enter. Then, from inside of the directory, run:

npm install @turnkey/sdk-server @turnkey/sdk-browser @turnkey/sdk-react - legacy-peer-deps

The — legacy-peer-deps flag ensures compatibility with different package versions for your project. Now you can open the directory up in your favorite code editor, and run npm run dev to start a local dev server.

Initial Configuration

Create a .env file to store all of your environment variables. We’ll start with our Turnkey credentials.

NEXT_PUBLIC_TURNKEY_RP_ID="localhost"
TURNKEY_API_PRIVATE_KEY="YOUR_API_PRIVATE_KEY_HERE"
TURNKEY_API_PUBLIC_KEY="YOUR_API_PUBLIC_KEY_HERE" 
TURNKEY_ORGANIZATION_ID="YOUR_TURNKEY_ORGANIZATION_ID_HERE"

You can generate an API keypair on Turnkey by heading to your Profile -> User Details, and then clicking Create API Key. Make sure that these stay private, and aren’t committed to your GitHub repository.

We set the rpId as localhost here for testing purposes, but in production you would need to switch this to the domain your web app is hosted on.

Updating Layout.tsx

We can remove most of the boilerplate code from the layout.tsx file.

The layout is essentially code that will wrap every page we create for our web application — so in this page, we will set up a Turnkey Provider to wrap the application. This makes Turnkey’s functionality available for when we generate passkeys on the client later on.

We also set up some basic metadata, like the web page’s title and description.

import type { Metadata } from "next";
import "./globals.css";
import { TurnkeyProvider } from "@turnkey/sdk-react";

export const metadata: Metadata = {
  title: "Turnkey Passkey Web App",
  description: "Web App with Turnkey Passkey Authentication",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`antialiased`}
      >
        <TurnkeyProvider
          config={{
            rpId: process.env.NEXT_PUBLIC_TURNKEY_RP_ID,
            apiBaseUrl: process.env.NEXT_PUBLIC_TURNKEY_API_BASE_URL,
            defaultOrganizationId: process.env.TURNKEY_ORGANIZATION_ID,
          }}
        >
        {children}
        </TurnkeyProvider>
      </body>
    </html>
  );
}

Setting some CSS globals

To keep things simple, we’ll update the globals.css file to just use the Tailwind directives, and theme our page separately.

@tailwind base;
@tailwind components;
@tailwind utilities;

Now we can create our page for users to sign up to our application with a Passkey.

Creating a Sign-Up Page

In page.tsx remove the boilerplate code, and add the following:

'use client';

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTurnkey } from "@turnkey/sdk-react";

export default function Home() {
  const router = useRouter();
  const { passkeyClient } = useTurnkey();
  const [email, setEmail] = useState("");
  const [username, setUsername] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  
  const createSubOrg = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsLoading(true);
    
    try {
      // Create a new passkey for the user
      const credential = await passkeyClient?.createUserPasskey({
        publicKey: {
          rp: {
            name: "Wallet Passkey",
          },
          user: {
            name: email,
            displayName: name,
          },
        },
      });

      if (!credential) {
        console.error("Failed to create passkey");
        setIsLoading(false);
        return;
      }

      console.log("Passkey created successfully");

      // Now create the sub-organization on the server
      const response = await fetch('/api/auth', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          type: "passkey",
          email,
          name,
          challenge: credential.encodedChallenge,
          attestation: credential.attestation,
        }),
      });

      const result = await response.json();
      
      router.push(`/wallet?organizationId=${result.organizationId}`);
    } catch (error) {
      console.error("Error:", error);
      setIsLoading(false);
    }
  };

Let’s break this down step by step.

Inside of our homepage, the first thing we do is set up some state variables for the form users will submit to sign up with a passkey. This includes a username and email, as well as a loading state for our form.

We also set up a router for page switching, and initialized Turnkey’s passkey helper. This will let us generate a Passkey for a user when they press a button below, and redirect them to a new page if they sign up successfully.

We then create a function called `createSubOrg`, which does exactly what it says — when the form is submitted and the button is pressed, it will prompt the user to generate a Passkey and send detials of the credential to the backend of our application (which we’ll set up shortly).

Inside of the return statement after this, we set up our HTML for the component which will actually display a form on the screen.

return (
    <div className="min-h-screen flex items-center justify-center p-4 bg-white">
      <div className="w-full max-w-md bg-white rounded-lg shadow-md p-6">
        <h1 className="text-2xl font-bold text-gray-800 text-center mb-6">Create Your Wallet</h1>
        
        <form onSubmit={createSubOrg} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
            <input
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900"
              placeholder="Your username"
              disabled={isLoading}
              required
            />
          </div>
          
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
            <input
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              type="email"
              className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900"
              placeholder="[email protected]"
              disabled={isLoading}
              required
            />
          </div>
          
          <button
            type="submit"
            disabled={isLoading}
            className="w-full py-2 px-4 bg-blue-600 text-white rounded-md font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
          >
            {isLoading ? "Creating..." : "Create Wallet with Passkey"}
          </button>
        </form>
      </div>
    </div>
  );

We take in a username and an email, and when the Create Wallet with Passkey button is pressed, the form will submit and the sign up process will take place.

In production, you would likely need to add error-handling and validation for this process, which is omitted for the sake for simplicity.

Setting up a Server-Side API Route

So we have a page where users can submit a sign-up form with a passkey — now we need something to actually happen when this button is pressed.

For this, we’ll set up a new API route which creates a sub-organization with a user’s credentials, and generates an Ethereum wallet for them.

Create a new path within your app directory such as api/auth/route.ts.

Inside of it, add the following code:

import { NextResponse } from "next/server";
import { Turnkey, DEFAULT_ETHEREUM_ACCOUNTS } from "@turnkey/sdk-server";


const turnkeyServer = new Turnkey({
  apiBaseUrl: process.env.NEXT_PUBLIC_TURNKEY_API_BASE_URL || "https://api.turnkey.com",
  apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY!,
  apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY!,
  defaultOrganizationId: process.env.TURNKEY_ORGANIZATION_ID!,
}).apiClient();

export async function POST(request: Request) {
  try {
    const body = await request.json();
    
    if (body.type !== "passkey") {
      return NextResponse.json(
        { message: "Unsupported authentication type" },
        { status: 400 }
      );
    }

    const { email, name, challenge, attestation, rpId } = body;
    const response = await turnkeyServer.createSubOrganization({
      subOrganizationName: `${name}'s Wallet`,
      rootUsers: [
        {
          userName: name,
          userEmail: email,
          apiKeys: [],
          authenticators: [
            {
              authenticatorName: "Default Passkey",
              challenge: challenge,
              attestation: attestation,
            },
          ],
          oauthProviders: [],
        },
      ],
      rootQuorumThreshold: 1,
      wallet: {
        walletName: "Default Wallet",
        accounts: DEFAULT_ETHEREUM_ACCOUNTS,
      },
    });

    return NextResponse.json({
      message: "Sub-organization created successfully",
      organizationId: response.subOrganizationId,
    });
  } catch (error: any) {
    console.error("Error creating sub-organization:", error);
    
    return NextResponse.json(
      { message: error.message || "Failed to create sub-organization" },
      { status: 500 }
    );
  }
}

The first thing we do is set up a Turnkey client using our API keys. This is an API route which executes server-side, as we don’t want to expose our API keys publicly on the frontend.

Using our Turnkey client, we simply accept the request containing the passkey credentials, and call createSubOrganization to create a sub-organization for the user, using their passkey as the auth method.

The rootQuorumThreshold is 1, meaning they are the only user who can access the sub-organization, and we pass in the default Ethereum curve settings to generate a wallet.

We then return the sub-organization ID in the response, so we can easily display it to the user.

Creating a wallet page

The last thing to do is create a wallet page.

In our main page.tsx we set up a redirect once a sub-organization is created, to redirect to a wallet page. Now, we need to actually create a wallet page for this to happen.

Create a new path wallet/page.tsx in your app directory, and add the following code:

'use client';

import { useSearchParams, useRouter } from 'next/navigation';

export default function Wallet() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const organizationId = searchParams.get('organizationId');

  return (
    <div className="min-h-screen flex items-center justify-center p-4 bg-white">
      <div className="w-full max-w-md bg-white rounded-lg shadow-md p-6">
        <h1 className="text-2xl font-bold text-gray-800 text-center mb-6">Your Wallet</h1>
        
        <div className="space-y-4">
          <div className="border border-gray-200 rounded-lg p-4">
            <p className="text-sm text-gray-500">Organization ID</p>
            <p className="font-mono text-sm break-all">{organizationId}</p>
          </div>
          
          <p className="text-center text-gray-600">
            Your Turnkey sub-organization has been created successfully!
          </p>
          
          <button
            onClick={() => router.push('/')}
            className="w-full py-2 px-4 bg-blue-600 text-white rounded-md font-medium hover:bg-blue-700"
          >
            Back to Home
          </button>
        </div>
      </div>
    </div>

We use useSearchParams to get the sub-organization ID passed in the URL so we can display it on the page, and useRouter to create a button send users back to the home page.

What’s next?

Congratulations, you just set up a non-custodial, passkey-powered app using Turnkey!

From here you can test it out yourself locally, add integrations to let users fund their wallets, display their balance, and set up granular auth control so users can only access data from their own sub-organization ID.

Check out our docs to see what you can build, and join our Slack for more technical tips.