> ## Documentation Index
> Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# NextAuth & Dynamic

<Note>
  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](/javascript/reference/react-quickstart) for the full setup.
</Note>

## Pre-requisites

* Cloned [the nextAuth example repo](https://github.com/nextauthjs/next-auth-example) and installed dependencies.

## Steps

### Add the right env variables

You'll need to define two environment variables in your `.env.local` file:

```bash theme={"system"}
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](https://app.dynamic.xyz/dashboard/developer/api). 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

```bash theme={"system"}
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.

```ts app/lib/dynamicClient.ts theme={"system"}
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:

```tsx app/components/dynamic-provider-wrapper.tsx theme={"system"}
"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`:

```tsx app/layout.tsx theme={"system"}
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`:

```tsx app/components/login.tsx theme={"system"}
"use client";

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

export default function Login() {
  const { data: user } = useUser();
  const { data: walletAccounts = [] } = useGetWalletAccounts();
  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:

```tsx app/components/header.tsx theme={"system"}
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:

```ts app/lib/authHelpers.ts theme={"system"}
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:

```bash theme={"system"}
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:

```ts app/lib/authHelpers.ts theme={"system"}
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](https://next-auth.js.org/configuration/providers/credentials) 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:

```ts ./auth.ts theme={"system"}
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`:

```tsx app/components/nextauth-bridge.tsx theme={"system"}
"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:

```tsx app/components/dynamic-provider-wrapper.tsx theme={"system"}
"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.

<Warning>
  **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
</Warning>

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:

```ts ./auth.ts theme={"system"}
...
    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

```bash theme={"system"}
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.
