Authentication with WorkOS in Next.js: A Comprehensive Guide

Background

Implementing authentication in frontend applications can be a complex and challenging task. There are various aspects to consider, such as creating and managing user accounts, implementing login flows, handling email verification, two-factor authentication (2FA), OAuth integration, password reset functionality, user profile management, and more. Failing to properly address these concerns can lead to security vulnerabilities and a poor user experience.

One of the primary challenges is securely managing user sessions and ensuring that sensitive data, such as passwords, is stored and transmitted safely. Implementing robust authentication logic from scratch can be time-consuming and error-prone, as it requires a deep understanding of security best practices and cryptography principles.

When adding authentication to an app, we can choose between two options: a managed authentication service or an unmanaged authentication service (writing our own logic). Each option has its pros and cons.

OptionManaged Authentication ServiceUnmanaged Authentication Service
ProsOut-of-the-box components, easy to implement, security out-of-the-box, easy to implementFlexibility, free, open source
ConsCosts, less flexible, some parts are not open sourceMore time and effort to implement, vulnerable to security issues, maintenance cost, hard to get right

The conclusion was to prefer a managed provider to move faster and avoid security pitfalls, and the costs were manageable.

Choosing WorkOS

In my previous company, Youleap, I used Clerk (after migrating from Auth0) as the authentication provider, and I was satisfied with it. Clerk provided many out-of-the-box components. At that time, the Clerk Elements feature, which allows using headless components, did not exist.

Clerk is free for up to 10,000 monthly active users and then costs $0.02 per user. Therefore, it is a less cost-effective option for products where the customer does not have to pay.

After researching additional authentication solutions, I found WorkOS. They provide functions that integrate well with React’s server actions, and their pricing is free for up to 1,000,000 users, which is significantly more cost-effective for products with a large user base.

WorkOS offers two ways to integrate:

  1. Hosted AuthKit: Their UI, similar to Auth0, providing pre-built authentication components and flows.
  2. Bring your own UI: Write your components from scratch and connect to the actions provided by WorkOS. It requires more effort, but allows more flexibility in the UI.

Because we have our custom auth flow, and users can view the website without logging in, we will use the workos actions without authkit.

Implementing WorkOS

As a reference, you can look at authkit examples repository - it has examples of WorkOS auth function usage. Also, you can look at authkit-nextjs repository for understanding the implementation of the Next.js SDK abstraction.

We start by adding environment variables in .env and validating them with @t3-oss/env-nextjs. This package helps manage and type-check environment variables in Next.js applications, ensuring that the required variables are present and have the correct format.

import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    WORKOS_API_KEY: z.string().min(1),
    WORKOS_CLIENT_ID: z.string().min(1),
    WORKOS_REDIRECT_URI: z.string().min(1),
    WORKOS_COOKIE_PASSWORD: z.string().min(1),
  },
});

Then we initialize the WorkOS object.

import WorkOS from "@workos-inc/node";
import { env } from "@/env";

export const workos = new WorkOS(env.WORKOS_API_KEY);

And update the middleware to update the WorkOS session on each request (the middleware calls updateSession internally). The middleware is a crucial component in Next.js that allows intercepting and modifying incoming requests and outgoing responses. In the context of authentication, the middleware is responsible for updating the user’s session on each request, ensuring that the session remains valid and up-to-date.

import { authkitMiddleware } from "@workos-inc/authkit-nextjs";

export async function middleware(req: NextRequest, event: NextFetchEvent) {
  return await authkitMiddleware()(req, event);

Now we need to define a callback route for our auth, depending on our env variables. This route is responsible for handling the callback from the authentication provider (in this case, WorkOS) after the user has successfully authenticated. The handleAuth function provided by the @workos-inc/authkit-nextjs package handles the necessary logic for exchanging the authorization code for an authenticated User object. I put it in src/app/(auth)/callback/route.ts.

import { handleAuth } from "@workos-inc/authkit-nextjs";

export const GET = handleAuth();

Now we will define a set of constants for the cookie - copied from authkit-nextjs. Notice that @workos-inc/authkit-nextjs depend on the cookieName in the internal implementation - so I had to use the same value. I opened a pull request to export those constants.

export const cookieName = "wos-session";
export const cookieOptions = {
  path: "/",
  httpOnly: true,
  secure: true,
  sameSite: "lax" as const,
};

Now we will create a function setWorkOSAuthCookie. This function is responsible for securely storing the user’s session data in a cookie. It uses the iron-session library to encrypt the session data and set it as a cookie with the appropriate options (path, httpOnly, secure, and sameSite).

import "server-only";
import type { User } from "@workos-inc/node";
import { sealData } from "iron-session";
import { cookies } from "next/headers";
import { env } from "@/env";
import { cookieName, cookieOptions } from "@/server/workos/cookie";

interface Impersonator {
  email: string;
  reason: string | null;
}

interface Session {
  accessToken: string;
  refreshToken: string;
  user: User;
  impersonator?: Impersonator;
}

async function encryptSession(session: Session) {
  return await sealData(session, { password: env.WORKOS_COOKIE_PASSWORD });
}

export async function setWorkOSAuthCookie(sessionData: Session) {
  const session = await encryptSession(sessionData);
  cookies().set(cookieName, session, cookieOptions);
}

Now we can create an account with a server action. Notice that in server actions, if there is an error, we will return an error format like {code: "code", error: "error message"} instead of throwing the error. Server actions in Next.js allow executing server-side code directly from the client, enabling scenarios like form submissions, data fetching, and other operations that require server-side processing.

In this case, the createAccount server action handles the creation of a new user account. It parses the form data, creates a new user in WorkOS, authenticates the user with their password, and sets the authentication cookie using the setWorkOSAuthCookie function.

"use server";

import "server-only";
import { pathFor } from "@nirtamir2/next-static-paths";
import { redirect } from "next/navigation";
import { z } from "zod";
import { env } from "@/env";
import { handleWorkOSError } from "@/server/workos/handleWorkOSError";
import { setWorkOSAuthCookie } from "@/server/workos/setWorkOSAuthCookie";
import { workos } from "@/server/workos/workos";

const formDataSchema = z.object({
  email: z.string().email(),
  password: z.string(),
  firstName: z.string(),
  lastName: z.string(),
});

export async function createAccount(_: unknown, formData: FormData) {
  try {
    const { email, password, firstName, lastName } = formDataSchema.parse({
      email: formData.get("email"),
      password: formData.get("password"),
      firstName: formData.get("firstName"),
      lastName: formData.get("lastName"),
    });

    await workos.userManagement.createUser({
      firstName,
      lastName,
      email,
      password,
    });

    // Sign in after creating the account is required
    const signInUser = await workos.userManagement.authenticateWithPassword({
      email,
      password,
      clientId: env.WORKOS_CLIENT_ID,
    });

    await setWorkOSAuthCookie(signInUser);
  } catch (error) {
    return handleWorkOSError(error);
  }

  redirect(pathFor("/"));
}

We need to handle errors from WorkOS. Due to the unknown error schema, I check the error type with Zod and handle those errors based on a defined structure. Zod is a TypeScript-first schema validation library that helps ensure type safety and provides a robust way to handle and parse errors.

import { ZodError, z } from "zod";

const emailVerificationErrorSchema = z.object({
  status: z.number(),
  rawData: z.object({
    code: z.literal("email_verification_required"),
    message: z.string(),
    email: z.string().email(),
    pending_authentication_token: z.string(),
  }),
  requestID: z.string(),
  name: z.string(),
  message: z.string(),
});

const genericWorkOSErrorSchema = z.object({
  status: z.number(),
  rawData: z.object({
    code: z.string(),
    message: z.string(),
  }),
  requestID: z.string(),
  name: z.string(),
  message: z.string(),
});

const userCreationErrorSchema = z.object({
  status: z.number(),
  requestID: z.string(),
  code: z.string(), //"user_creation_error", "password_strength_error", "password_reset_error"
  errors: z.array(
    z.object({
      code: z.string(),
      message: z.string(),
    }),
  ),
});

export type WorkOSError = ReturnType<typeof handleWorkOSError>;

export function handleWorkOSError(error: unknown) {
  if (error instanceof ZodError) {
    return {
      code: "zod_error",
      errors: error.errors.map((e) => {
        return {
          code: e.code,
          message: e.message,
        };
      }),
    };
  }

  const safeParsedEmailVerificationRequiredError =
    emailVerificationErrorSchema.safeParse(error);
  if (safeParsedEmailVerificationRequiredError.success) {
    return {
      code: safeParsedEmailVerificationRequiredError.data.rawData.code,
      error: safeParsedEmailVerificationRequiredError.data.message,
      email: safeParsedEmailVerificationRequiredError.data.rawData.email,
      pendingAuthenticationToken:
        safeParsedEmailVerificationRequiredError.data.rawData
          .pending_authentication_token,
    };
  }

  const safeParsedUserCreationError = userCreationErrorSchema.safeParse(error);
  if (safeParsedUserCreationError.success) {
    return {
      code: safeParsedUserCreationError.data.code,
      errors: safeParsedUserCreationError.data.errors,
    };
  }

  const safeParsedGenericWorkOSError =
    genericWorkOSErrorSchema.safeParse(error);
  if (safeParsedGenericWorkOSError.success) {
    return {
      code: safeParsedGenericWorkOSError.data.rawData.code,
      error: safeParsedGenericWorkOSError.data.message,
    };
  }

  return {
    error: JSON.stringify(error),
    code: "unknown",
  };
}

Now we can consume the server action with the client. This code snippet demonstrates how to use the createAccount server action from the client-side. It leverages the useActionState hook from React to manage the state of the server action and handle any errors that may occur.

"use client";

import React, { useActionState, useId } from "react";
import { EmailVerificationForm } from "@/components/auth/EmailVerificationForm";
import { ErrorMessage } from "@/components/auth/ErrorMessage";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Label } from "@/components/ui/Label";
import { PasswordInput } from "@/components/ui/PasswordInput";
import { createAccount } from "@/server/actions/createAccount";

export function CreateAccountForm(props: Props) {
  const emailId = useId();
  const passwordId = useId();
  const firstNameId = useId();
  const lastNameId = useId();

  const [createAccountState, createAccountAction, isPending] = useActionState(
    createAccount,
    null,
  );

  if (
    createAccountState != null &&
    createAccountState.code === "email_verification_required" &&
    createAccountState.pendingAuthenticationToken != null
  ) {
    return (
      <EmailVerificationForm
        email={createAccountState.email}
        pendingAuthenticationToken={
          createAccountState.pendingAuthenticationToken
        }
      />
    );
  }

  return (
    <form className="flex flex-col gap-2" action={createAccountAction}>
      <div className="text-center text-xl font-bold">Create Account</div>
      <Label htmlFor={firstNameId}>First Name</Label>
      <Input required name="firstName" id={firstNameId} />
      <Label htmlFor={lastNameId}>Last Name</Label>
      <Input required name="lastName" id={lastNameId} />

      <div>
        <Label htmlFor={emailId}>Email</Label>
        <Input
          required
          type="email"
          autoCapitalize="off"
          autoComplete="username"
          name="email"
          id={emailId}
          placeholder="example@email.com"
        />
      </div>
      <div>
        <Label htmlFor={passwordId}>Password</Label>
        <PasswordInput
          required
          name="password"
          id={passwordId}
          placeholder="!*$_!"
          autoCapitalize="off"
          autoComplete="current-password"
        />
      </div>
      <div className="pt-2">
        <Button isPending={isPending} type="submit">
          Create Account
        </Button>
      </div>
      {createAccountState == null ? null : (
        <ErrorMessage error={createAccountState} />
      )}
    </form>
  );
}

We might get an error about an unverified email, so we can take the token and create an OTP (One-Time Password) form to validate the email. In some cases, WorkOS may require email verification before allowing the user to proceed. This component handles the scenario where email verification is required by displaying an OTP form for the user to enter the verification code sent to their email.

import React, { useActionState } from "react";
import { ErrorMessage } from "@/components/auth/ErrorMessage";
import { Button } from "@/components/ui/Button";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/InputOTP";
import { verifyEmailWithCode } from "@/server/actions/verifyEmailWithCode";

type Props = {
  pendingAuthenticationToken: string;
  email: string;
};

const OTPLength = 6;

export function EmailVerificationForm(props: Props) {
  const { pendingAuthenticationToken, email } = props;

  const [verifyEmailWithCodeState, verifyEmailWithCodeAction, isPending] =
    useActionState(verifyEmailWithCode, null);

  return (
    <form action={verifyEmailWithCodeAction} className="flex flex-col gap-2">
      <div className="text-center text-xl font-bold">Verify Your Email</div>
      <div className="text-sm">We sent you a one-time password to {email}</div>
      <input
        className="hidden"
        name="pendingAuthenticationToken"
        value={pendingAuthenticationToken}
      />
      <InputOTP maxLength={OTPLength} name="code">
        <InputOTPGroup>
          {Array.from({ length: OTPLength }).map((_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
      <div className="pt-2">
        <Button isPending={isPending} type="submit">
          Submit
        </Button>
      </div>
      {verifyEmailWithCodeState == null ? null : (
        <ErrorMessage error={verifyEmailWithCodeState} />
      )}
    </form>
  );
}

Log in

Now we can create a similar thing with the log-in process. This section covers the implementation of the login flow, which follows a similar pattern to the account creation process.

import React, { useActionState, useId } from "react";
import { EmailVerificationForm } from "@/components/auth/EmailVerificationForm";
import { ErrorMessage } from "@/components/auth/ErrorMessage";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Label } from "@/components/ui/Label";
import { PasswordInput } from "@/components/ui/PasswordInput";
import { signIn } from "@/server/actions/signIn";

export function LoginForm(props: { onForgetPassword: () => void }) {
  const { onForgetPassword } = props;

  const emailId = useId();
  const passwordId = useId();

  const [signInState, signInAction, isPending] = useActionState(signIn, null);

  if (
    signInState != null &&
    signInState.code === "email_verification_required" &&
    signInState.pendingAuthenticationToken != null
  ) {
    return (
      <EmailVerificationForm
        pendingAuthenticationToken={signInState.pendingAuthenticationToken}
        email={signInState.email}
      />
    );
  }

  return (
    <form action={signInAction} className="flex flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor={emailId}>Email</Label>
        <Input
          required
          type="email"
          autoCapitalize="off"
          autoComplete="username"
          name="email"
          id={emailId}
          placeholder="Your email"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor={passwordId}>Password</Label>
        <PasswordInput
          required
          name="password"
          id={passwordId}
          placeholder="Your password"
          autoCapitalize="off"
          autoComplete="current-password"
        />
        <div className="flex justify-end">
          <Button
            variant="smallLink"
            size="bare"
            onClick={() => {
              onForgetPassword();
            }}
          >
            Forgot your password?
          </Button>
        </div>
      </div>
      {signInState == null ? null : <ErrorMessage error={signInState} />}
      <Button isPending={isPending} type="submit">
        Log in
      </Button>
    </form>
  );
}

With the server action to sign in:

"use server";

import "server-only";
import { pathFor } from "@nirtamir2/next-static-paths";
import { redirect } from "next/navigation";
import { z } from "zod";
import { env } from "@/env";
import { handleWorkOSError } from "@/server/workos/handleWorkOSError";
import { setWorkOSAuthCookie } from "@/server/workos/setWorkOSAuthCookie";
import { workos } from "@/server/workos/workos";

const formDataSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

export async function signIn(_: unknown, formData: FormData) {
  try {
    const { email, password } = formDataSchema.parse({
      email: formData.get("email"),
      password: formData.get("password"),
    });

    const user = await workos.userManagement.authenticateWithPassword({
      clientId: env.WORKOS_CLIENT_ID,
      email,
      password,
    });

    await setWorkOSAuthCookie(user);
  } catch (error) {
    return handleWorkOSError(error);
  }
  redirect(pathFor("/"));
}

Forget Password

First, we need to trigger the forget password action. It will send the user an email with a link to reset the password. This section covers the implementation of the “Forgot Password” functionality, which allows users to initiate the process of resetting their password by receiving a password reset link via email.

import React, { useActionState, useId } from "react";
import { ErrorMessage } from "@/components/auth/ErrorMessage";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Label } from "@/components/ui/Label";
import { sendResetPasswordEmail } from "@/server/actions/sendResetPasswordEmail";

export function ForgotPasswordForm() {
  const emailId = useId();

  const [sendResetPasswordEmailState, sendResetPasswordEmailAction, isPending] =
    useActionState(sendResetPasswordEmail, null);

  return (
    <form action={sendResetPasswordEmailAction} className="flex flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor={emailId}>Email</Label>
        <Input
          required
          type="email"
          autoCapitalize="off"
          autoComplete="username"
          name="email"
          id={emailId}
          placeholder="Your email"
        />
      </div>
      {sendResetPasswordEmailState ==
      null ? null : sendResetPasswordEmailState.code == null ? (
        <div className="text-sm">
          Please check your email {sendResetPasswordEmailState.email}
        </div>
      ) : (
        <ErrorMessage error={sendResetPasswordEmailState} />
      )}
      <Button isPending={isPending} type="submit">
        Reset password
      </Button>
    </form>
  );
}

and its server action:

"use server";

import "server-only";
import { z } from "zod";
import { getBaseUrl } from "@/lib/getBaseUrl";
import { handleWorkOSError } from "@/server/workos/handleWorkOSError";
import { workos } from "@/server/workos/workos";

const formDataSchema = z.object({
  email: z.string().email(),
});

export async function sendResetPasswordEmail(_: unknown, formData: FormData) {
  try {
    const { email } = formDataSchema.parse({
      email: formData.get("email"),
    });
    await workos.userManagement.sendPasswordResetEmail({
      email,
      passwordResetUrl: `${getBaseUrl()}/reset-password?email=${email}`,
    });
    return { code: null, email };
  } catch (error) {
    return handleWorkOSError(error);
  }
}

Then we need to make the page to reset the password after the user enters this link. This code snippet represents the page component responsible for rendering the password reset form. It checks if the user has a valid token and email in the URL query parameters and renders either the “Forgot Password” form or the “Reset Password” form accordingly.

"use client";

import React from "react";
import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";

export default function ResetPasswordPage({
  searchParams,
}: {
  searchParams: { token?: string; email?: string };
}) {
  const { token, email } = searchParams;

  return (
    <main key="email" className="flex flex-col items-center pt-28">
      <div className="flex w-72 flex-col gap-4">
        <div className="text-center text-xl font-bold">Reset password</div>
        {token == null ? (
          <ForgotPasswordForm />
        ) : (
          <ResetPasswordForm token={token} email={email} />
        )}
      </div>
    </main>
  );
}

And the form to reset the password (notice the hidden input to send the token to the server action): This component renders the form for resetting the user’s password.

import React, { useActionState } from "react";
import { ErrorMessage } from "@/components/auth/ErrorMessage";
import { Button } from "@/components/ui/Button";
import { Label } from "@/components/ui/Label";
import { PasswordInput } from "@/components/ui/PasswordInput";
import { resetPassword } from "@/server/actions/resetPassword";

export function ResetPasswordForm(props: {
  token: string;
  email: string | undefined;
}) {
  const { email, token } = props;

  const [resetPasswordState, resetPasswordAction, isPending] = useActionState(
    resetPassword,
    null,
  );
  return (
    <form action={resetPasswordAction} className="flex flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="newPassword">New Password</Label>
        <PasswordInput
          required
          name="newPassword"
          id="newPassword"
          autoCapitalize="off"
          autoComplete="new-password"
        />
      </div>

      <input type="hidden" name="token" value={token} />

      {email == null ? null : (
        <input
          type="hidden"
          name="email"
          value={email}
          autoComplete="username"
        />
      )}

      {resetPasswordState == null ? null : (
        <ErrorMessage error={resetPasswordState} />
      )}
      <Button isPending={isPending} type="submit">
        Continue
      </Button>
    </form>
  );
}

With the server action:

"use server";

import "server-only";
import { pathFor } from "@nirtamir2/next-static-paths";
import { redirect } from "next/navigation";
import { z } from "zod";
import { handleWorkOSError } from "@/server/workos/handleWorkOSError";
import { workos } from "@/server/workos/workos";

const formDataSchema = z.object({
  token: z.string(),
  newPassword: z.string(),
});

export async function resetPassword(_: unknown, formData: FormData) {
  try {
    const { token, newPassword } = formDataSchema.parse({
      token: formData.get("token"),
      newPassword: formData.get("newPassword"),
    });

    await workos.userManagement.resetPassword({ newPassword, token });
  } catch (error) {
    return handleWorkOSError(error);
  }

  redirect(pathFor("/"));
}

Logout

Creating the logout button:

"use client";

import React, { useActionState } from "react";
import { ErrorMessage } from "@/components/auth/ErrorMessage";
import { logout } from "@/components/auth/actions/logout";
import { Button } from "@/components/ui/Button";

export function LogoutForm() {
  const [logoutState, logoutAction, isPending] = useActionState(logout, null);
  return (
    <form className="flex justify-center" action={logoutAction}>
      {logoutState == null ? null : <ErrorMessage error={logoutState} />}
      <Button isPending={isPending} type="submit" variant="smallLink">
        Logout
      </Button>
    </form>
  );
}

Together with the server action:

"use server";

import { signOut } from "@workos-inc/authkit-nextjs";

export async function logout() {
  await signOut();
}

Rendering the Logged User

We can use a server component to render the logged user. Calling await getUser() will return the user if they’re already logged in. This section demonstrates how to use a server component to render the logged-in user’s information. The getUser function provided by @workos-inc/authkit-nextjs retrieves the authenticated user’s data, which can then be used to render an avatar or other user-specific components.

import { getUser } from "@workos-inc/authkit-nextjs";

export async function User() {
  const { user } = await getUser();

  if (user != null) {
    return <Avatar size="medium" user={user} />;
  }

  return <div>User not logged in</div>;
}

Syncing the backend and enriching the user data

To keep the backend user data synchronized with WorkOS, we leverage WorkOS webhooks. Webhooks are HTTP callbacks that WorkOS sends to a specified URL whenever certain events occur, such as user creation, update, or deletion. By listening to these events, we can update our database accordingly, ensuring that our user records remain in sync with WorkOS.

For example, when a new user is created in WorkOS, a "user.created" webhook event is triggered. We can listen for this event and create a corresponding user record in our database. Similarly, when a user’s information is updated or deleted in WorkOS, we can update or remove the corresponding record in our database by listening to the "user.updated" and "user.deleted" events, respectively.

The webhook integration approach allows us to maintain a consistent and up-to-date user data store across multiple systems, ensuring data integrity and enabling seamless data flow between our application and WorkOS.

Conclusion

In this article, I created an authentication system with user and password using WorkOS. The implementation covers various aspects of authentication, including account creation, login, password reset, and email verification flows. By leveraging WorkOS’s server actions and integrating with their API, we can build a robust and secure authentication system tailored to our application’s needs.

In the future, I plan to add MFA (Multi-Factor Authentication) to enhance the security of the authentication process. Implementing MFA will require handling additional error scenarios, and add more complexity like in this example.

Overall, WorkOS has proven to be a reliable and user-friendly authentication solution. Their support channel helped me navigate any challenges during the early stages of adoption, as I was one of the early adopters of their platform.

It’s worth noting that at the time of writing, Next.js did not support React 19’s useActionState hook. As a workaround, I used the useFormState hook from react-dom, which provides similar functionality for managing form state and server actions.