Skip to main content
This recipe uses the Dynamic JavaScript SDK (@dynamic-labs-sdk/client + @dynamic-labs-sdk/react-hooks). The SDK is headless — render your own auth UI and call the SDK’s auth functions on submit. See the React Quickstart for the full setup.

Pre-requisites

Steps

Add the right env variables

You’ll need to define two environment variables in your .env.local file:
NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=
NEXT_DYNAMIC_BEARER_TOKEN=
You’ll be able to find both in the SDK & API Keys page of the Dynamic dashboard. The first will already be generated for you but for the API key, you’ll need to generate your own via the UI on that page. Make sure you add the values of each variable to the .env.local file, and you’re good to go.

Install the Dynamic JavaScript SDK

npm install @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/react-hooks

Create the Dynamic client module

The client is a module-level singleton — create it once and import it from anywhere. Import this file at app root so extensions register before any component renders.
app/lib/dynamicClient.ts
import { createDynamicClient, initializeClient } from "@dynamic-labs-sdk/client";
import { addEvmExtension } from "@dynamic-labs-sdk/evm";

export const dynamicClient = createDynamicClient({
  environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID!,
  metadata: {
    name: "NextAuth + Dynamic",
    universalLink: typeof window !== "undefined" ? window.location.origin : "",
  },
});

addEvmExtension();
void initializeClient();

Add the DynamicProvider

In Next.js App Router, providers need to live in a client component so they can hold reactive state. Create a wrapper and import the client module so extensions register on mount:
app/components/dynamic-provider-wrapper.tsx
"use client";

import { DynamicProvider } from "@dynamic-labs-sdk/react-hooks";
import { dynamicClient } from "../lib/dynamicClient";

export default function ProviderWrapper({ children }: React.PropsWithChildren) {
  return <DynamicProvider client={dynamicClient}>{children}</DynamicProvider>;
}
Now mount the wrapper in app/layout.tsx:
app/layout.tsx
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Footer from "@/components/footer";
import Header from "@/components/header";
import ProviderWrapper from "@/components/dynamic-provider-wrapper";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "NextAuth.js Example",
  description:
    "This is an example site to demonstrate how to use NextAuth.js for authentication",
};

export default function RootLayout({ children }: React.PropsWithChildren) {
  return (
    <html lang="en">
      <ProviderWrapper>
        <body className={inter.className}>
          <div className="flex flex-col justify-between w-full h-full min-h-screen">
            <Header />
            <main className="flex-auto w-full max-w-3xl px-4 py-4 mx-auto sm:px-6 md:py-6">
              {children}
            </main>
            <Footer />
          </div>
        </body>
      </ProviderWrapper>
    </html>
  );
}

Build a login component

The JS SDK is headless — there’s no built-in widget. Build a minimal login form using sendEmailOTP and verifyOTP:
app/components/login.tsx
"use client";

import { useState } from "react";
import { sendEmailOTP, verifyOTP } from "@dynamic-labs-sdk/client";
import type { OtpVerification } from "@dynamic-labs-sdk/client";
import { useUser, useWalletAccounts } from "@dynamic-labs-sdk/react-hooks";
import { logout } from "@dynamic-labs-sdk/client";

export default function Login() {
  const user = useUser();
  const walletAccounts = useWalletAccounts();
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [pending, setPending] = useState<OtpVerification | null>(null);

  if (user) {
    return (
      <div>
        <span>{user.email ?? walletAccounts[0]?.address}</span>
        <button onClick={() => logout()}>Log out</button>
      </div>
    );
  }

  if (!pending) {
    return (
      <>
        <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" />
        <button
          onClick={async () => {
            const { otpVerification } = await sendEmailOTP({ email });
            setPending(otpVerification);
          }}
        >
          Send code
        </button>
      </>
    );
  }

  return (
    <>
      <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
      <button
        onClick={async () => {
          await verifyOTP({ otpVerification: pending, verificationToken: code });
          setPending(null);
        }}
      >
        Verify
      </button>
    </>
  );
}
Mount it in your header:
app/components/header.tsx
import { MainNav } from "./main-nav";
import UserButton from "./user-button";
import Login from "./login";

export default function Header() {
  return (
    <header className="sticky flex justify-center border-b">
      <div className="flex items-center justify-between w-full h-16 max-w-3xl px-4 mx-auto sm:px-6">
        <MainNav />
        <Login />
        <UserButton />
      </div>
    </header>
  );
}
Now we’re almost done with the client side. We’ll need to somehow send the JWT that Dynamic returns on login to our server functions so that we can validate it and create a session. To do this we’ll use the tokenChanged event Dynamic provides, but let’s come back to that and first add the server side code.

Define the JWT decoding

NextAuth needs to know how to decode and validate the JWT which Dynamic sends back. To do this we’ll create a custom JWT decoder inside a new helper file:
app/lib/authHelpers.ts
import jwt, { JwtPayload, Secret, VerifyErrors } from "jsonwebtoken";

export const validateJWT = async (
  token: string
): Promise<JwtPayload | null> => {
  try {
    const decodedToken = await new Promise<JwtPayload | null>(
      (resolve, reject) => {
        jwt.verify(
          token,
          getKey,
          { algorithms: ["RS256"] },
          (
            err: VerifyErrors | null,
            decoded: string | JwtPayload | undefined
          ) => {
            if (err) {
              reject(err);
            } else if (typeof decoded === "object" && decoded !== null) {
              resolve(decoded);
            } else {
              reject(new Error("Invalid token"));
            }
          }
        );
      }
    );
    return decodedToken;
  } catch (error) {
    console.error("Invalid token:", error);
    return null;
  }
};
You’ll see that the above function depends on a few things, one of which is the external jsonwebtoken library. We’ll need to install this:
npm install jsonwebtoken
Next we’ll need to define the getKey function which is used to fetch the public key which you can use to decode the JWT. This function will make an API call to Dynamic. We’ll add this to the same file:
app/lib/authHelpers.ts
export const getKey = (
  headers,
  callback: (err: Error | null, key?: Secret) => void
): void => {
  const options = {
    method: "GET",
    headers: {
      Authorization: `Bearer ${process.env.NEXT_DYNAMIC_BEARER_TOKEN}`,
    },
  };

  fetch(
    `https://app.dynamicauth.com/api/v0/environments/${process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID}/keys`,
    options
  )
    .then((response) => response.json())
    .then((json) => {
      const publicKey = json.key.publicKey;
      const pemPublicKey = Buffer.from(publicKey, "base64").toString("ascii");
      callback(null, pemPublicKey);
    })
    .catch((err) => {
      console.error(err);
      callback(err);
    });
};
With this in place, there are just two steps left. Firstly we’ll need to adapt the NextAuth configuration to use the CredentialsProvider so the JWT works, and then we’ll need to trigger everything correctly when a user logs in.

Update the NextAuth configuration

You can copy the below code and paste it over the full existing auth.ts file in the demo repo:
./auth.ts
import NextAuth from "next-auth";

import type { NextAuthConfig } from "next-auth";

import Credentials from "@auth/core/providers/credentials";
import { validateJWT } from "./authHelpers";

type User = {
  id: string;
  name: string;
  email: string;
  scope?: string; // JWT scope (e.g. "user:basic"); verify scope includes user:basic before trusting
};

export const config = {
  theme: {
    logo: "https://next-auth.js.org/img/logo/logo-sm.png",
  },
  providers: [
    Credentials({
      name: "Credentials",
      credentials: {
        token: { label: "Token", type: "text" },
      },
      async authorize(
        credentials: Partial<Record<"token", unknown>>,
        request: Request
      ): Promise<User | null> {
        const token = credentials.token as string;
        if (typeof token !== "string" || !token) {
          throw new Error("Token is required");
        }
        const jwtPayload = await validateJWT(token);

        if (jwtPayload) {
          // CRITICAL: Verify the scope list includes 'user:basic' to confirm full authentication
          // scope is a space-separated list; any token without user:basic has NOT completed authentication
          const scopes = (jwtPayload.scope || "").split(" ");
          if (!scopes.includes("user:basic")) {
            console.error("Authentication incomplete - scope does not include user:basic");
            return null;
          }

          const user: User = {
            id: jwtPayload.sub,
            name: jwtPayload.name || "",
            email: jwtPayload.email || "",
            scope: jwtPayload.scope,
          };
          return user;
        } else {
          return null;
        }
      },
    }),
  ],
  callbacks: {
    authorized({ request, auth }) {
      const { pathname } = request.nextUrl;
      if (pathname === "/middleware-example") return !!auth;
      return true;
    },
  },
} satisfies NextAuthConfig;

export const { handlers, auth, signIn, signOut } = NextAuth(config);

Trigger the JWT validation on login

In the old React Core SDK you could pass an onAuthSuccess callback into DynamicContextProvider settings. The JS SDK is event-driven instead — listen for tokenChanged and forward the new JWT to NextAuth. Add a NextAuthBridge component inside DynamicProvider:
app/components/nextauth-bridge.tsx
"use client";

import { useEvent } from "@dynamic-labs-sdk/react-hooks";
import { getCsrfToken } from "next-auth/react";
import { dynamicClient } from "../lib/dynamicClient";

export default function NextAuthBridge() {
  useEvent({
    event: "tokenChanged",
    listener: async () => {
      const authToken = dynamicClient.token;
      if (!authToken) return; // logout fires tokenChanged with null

      const csrfToken = await getCsrfToken();

      const res = await fetch("/api/auth/callback/credentials", {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: `csrfToken=${encodeURIComponent(csrfToken)}&token=${encodeURIComponent(authToken)}`,
      });

      if (res.ok) {
        console.log("LOGGED IN", res);
      } else {
        console.error("Failed to log in");
      }
    },
  });

  return null;
}
Mount it once inside the provider wrapper:
app/components/dynamic-provider-wrapper.tsx
"use client";

import { DynamicProvider } from "@dynamic-labs-sdk/react-hooks";
import { dynamicClient } from "../lib/dynamicClient";
import NextAuthBridge from "./nextauth-bridge";

export default function ProviderWrapper({ children }: React.PropsWithChildren) {
  return (
    <DynamicProvider client={dynamicClient}>
      <NextAuthBridge />
      {children}
    </DynamicProvider>
  );
}
useEvent cleans up the subscription on unmount, so it’s safe under React Strict Mode. We’re using the getCsrfToken function which NextAuth provides — this is important because NextAuth uses CSRF tokens to prevent CSRF attacks.

Token Scopes

A JWT token includes a scope claim (a space-separated list of scopes) that indicates the user’s authentication state. You must verify that the scope list includes user:basic to confirm the user has fully completed authentication.
Critical: Verify that user:basic is present in the scope list — do not check for strict equality. A fully authenticated user may have additional scopes (e.g. from Access Lists or Gates), so the scope string can be e.g. user:basic beta-access. If user:basic is not among the scopes, the user has NOT completed the authentication flow and the JWT should not be trusted for protected operations. Common non-final scopes include:
  • requiresAdditionalAuth — User must complete MFA
  • Other intermediate scopes — User is still completing verification steps
Our SDK handles this for the frontend, but for the backend you will need to check this scope and handle it accordingly. To do this, add a scope verification check in the authorize function in the auth.ts file:
./auth.ts
...
    async authorize(
        credentials: Partial<Record<"token", unknown>>,
        request: Request
      ): Promise<User | null> {
        const token = credentials.token as string;
        if (typeof token !== "string" || !token) {
          throw new Error("Token is required");
        }
        const jwtPayload = await validateJWT(token);

        if (jwtPayload) {
          // CRITICAL: Verify the scope list includes 'user:basic' to confirm full authentication
          // scope is a space-separated list; any token without user:basic has NOT completed authentication
          const scopes = (jwtPayload.scope || "").split(" ");
          if (!scopes.includes("user:basic")) {
            console.error("Authentication incomplete - scope does not include user:basic");
            return null;
          }

          const user: User = {
            id: jwtPayload.sub,
            name: jwtPayload.name || "",
            email: jwtPayload.email || "",
            scope: jwtPayload.scope,
          };
          return user;
        } else {
          return null;
        }
      }
...
Important: Always reject tokens whose scope list does not include user:basic. These tokens represent incomplete authentication and should never be trusted for protected operations.

Run the example

npm run dev
You should now see a login form in the header which you can use to sign in with email. Once you’ve logged in you should see “LOGGED IN” in the browser console.

Going further

You’ll see in auth.ts that we are assigning certain JWT fields to a user object. You can add any fields you want to this object, and then access them in your pages via the useSession hook.