DEV Community

Cover image for Cookie-Based Authentication in Nextjs App Router Application
Rakesh Potnuru
Rakesh Potnuru

Posted on • Originally published at itsrakesh.com

Cookie-Based Authentication in Nextjs App Router Application

Published from Publish Studio

In the previous article "Implementing Cookie-Based JWT Authentication in a tRPC Backend", I shared how to secure the backend built with tRPC. Now it's time to secure the front end built with Next.js.

Adding authentication to Next.js is a piece of cake. Thanks to Next.js middleware, it's easier than ever to protect endpoints.

This is part 4 of the "Building a Full-Stack App with tRPC and Next.js" series. I recommended reading the first 3 parts if you haven't to better understand this part.

Let's learn how to secure our Finance Tracker (GitHub repo) frontend.

Enable Credentials

If you remember from the previous article, we are using cookie-based authentication. So whenever we log in, auth tokens are sent in response headers and the browser automatically stores them in cookies.

But before we do that, we have to enable credentials in the tRPC client, so that browser knows it has to send cookies in network requests.

Add fetch method and set credentials: "include"

// src/lib/providers/trpc.tsx

const trpcClient = trpc.createClient({
  transformer: superjson,
  links: [
    loggerLink({
      enabled: () => process.env.NODE_ENV === "development",
    }),
    httpBatchLink({
      url: process.env.NEXT_PUBLIC_TRPC_API_URL,
      fetch: (url, options) => { // <--- add fetch method
        return fetch(url, {
          ...options,
          credentials: "include", // <--- set this
        });
      },
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Now cookies will be sent in all requests.

Similarly, do this in the backend too.

// src/index.ts

app.use(
  cors({
    origin: "http://localhost:3000", // we need to specify origins in order for this to work, wildcard "*" doesn't work
    credentials: true, // <--- here
  })
);
Enter fullscreen mode Exit fullscreen mode

Now backend will allow requests containing cookies.

Create Login and Register Pages

Implement register

Since auth tokens are sent only after logging in, after registration, redirect the user to the login page.

This is how I usually structure my Next.js projects.

.
└── src/
    ├── app/
    │   ├── (auth)/
    │   │   ├── login/
    │   │   │   └── page.tsx
    │   │   └── register/
    │   │       └── page.tsx
    │   └── layout.tsx
    └── components/
        └── modules/
            └── auth/
                ├── login-form.tsx
                └── register-form.tsx
Enter fullscreen mode Exit fullscreen mode

app/(auth)/layout.tsx shares a common layout for login and register pages.

export default function AuthLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <main className="flex items-center justify-center min-h-screen">
      <div className="w-full max-w-md">{children}</div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/(auth)/register/page.tsx

import RegisterForm from "@/components/modules/auth/register-form";
import { Metadata } from "next";

export const metadata: Metadata = {
  title: "Register | Finance Tracker",
};

export default function LoginPage() {
  return <RegisterForm />;
}
Enter fullscreen mode Exit fullscreen mode
// app/components/modules/auth/register-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { trpc } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";

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

export default function RegisterForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const router = useRouter();

  const { mutateAsync: register, isLoading } = trpc.auth.register.useMutation({
    onSuccess: () => {
      router.replace("/login");
    },
  });

  const onSubmit = async (data: z.infer<typeof formSchema>) => {
    try {
      await register(data);
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>Create an account</CardTitle>
      </CardHeader>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)}>
          <CardContent className="space-y-4">
            <FormField
              control={form.control}
              name="email"
              disabled={isLoading}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <FormControl>
                    <Input
                      type="email"
                      placeholder="me@example.com"
                      autoComplete="email"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="password"
              disabled={isLoading}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <FormControl>
                    <Input
                      type="password"
                      placeholder="********"
                      autoComplete="new-password"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </CardContent>
          <CardFooter className="flex flex-col gap-4">
            <Button type="submit" className="w-full" disabled={isLoading}>
              Create account
            </Button>
            <p>
              Already have an account? <Link href="/login">Login</Link>
            </p>
          </CardFooter>
        </form>
      </Form>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

Implement login

Let's create a login page and test if our setup is working or not.

Create a login route.

// (auth)/login/page.tsx

import LoginForm from "@/components/modules/auth/login-form";
import { Metadata } from "next";

export const metadata: Metadata = {
  title: "Login | Finance Tracker",
};

export default function LoginPage() {
  return <LoginForm />;
}
Enter fullscreen mode Exit fullscreen mode

Finally, add a login form.

// src/components/modules/auth/login-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { trpc } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";

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

export default function LoginForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const router = useRouter();

  const { mutateAsync: login, isLoading } = trpc.auth.login.useMutation({
    onSuccess: () => {
      router.push("/");
    },
  });

  const onSubmit = async (data: z.infer<typeof formSchema>) => {
    try {
      await login(data);
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>Login to your account</CardTitle>
      </CardHeader>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)}>
          <CardContent className="space-y-4">
            <FormField
              control={form.control}
              name="email"
              disabled={isLoading}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <FormControl>
                    <Input
                      type="email"
                      placeholder="me@example.com"
                      autoComplete="email"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="password"
              disabled={isLoading}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <FormControl>
                    <Input
                      type="password"
                      placeholder="********"
                      autoComplete="current-password"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </CardContent>
          <CardFooter className="flex flex-col gap-4">
            <Button type="submit" className="w-full" disabled={isLoading}>
              Login
            </Button>
            <p>
              Don&apos;t have an account? <Link href="/register">Register</Link>
            </p>
          </CardFooter>
        </form>
      </Form>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's test this. Start backend and frontend. Navigate to http://localhost:3000/login. Try logging in with your email and password.

You will be redirected to the dashboard. Now, open browser console -> Application tab -> Cookies -> http://localhost:3000, you can see the stored cookies.

browser cookies

Protect Endpoints with Next.js Middleware

We almost had everything we needed.

Now, try to open /login and /register pages while being already logged in or dashboard (/) page while being not logged in, you are able to do so. But we shouldn't let that happen. Also, if you wait some time, accessToken and logged_in cookies disappear because they expire after 15 minutes. We have to fix this too.

Both of these problems can be solved by Next.js middleware.

Using this middleware, let's check if a user is logged in before showing a page. Accordingly, redirect the user to the appropriate page.

// src/middleware.ts

import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

export default async function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const pathname = request.nextUrl.pathname;
  const loggedIn = request.cookies.get("logged_in");

  const authUrls = new Set(["/login", "/register"]);

  if (loggedIn && authUrls.has(pathname)) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  return response;
}

// Don't run middleware for these files
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};
Enter fullscreen mode Exit fullscreen mode

Here, we are checking if logged_in cookie is present. If it is present and the user is trying to access auth endpoints, the user is redirected to the dashboard page.

Now let's implement a refreshing access token so that users don't have to log in every 15 minutes.

Before we move on, I've to tell you one thing. Nextjs middleware is a server component means the tRPC client created with createTRPCReact doesn't work. To make tRPC work server-side, tRPC provides createTRPCProxyClient.

Here's the tRPC server client:

// src/utils/trpc.ts

export const createTRPCServerClient = (headers: HTTPHeaders) =>
  createTRPCProxyClient<AppRouter>({
    transformer: superjson,
    links: [
      loggerLink({
        enabled: () => process.env.NODE_ENV === "development",
      }),
      httpBatchLink({
        url: process.env.NEXT_PUBLIC_TRPC_API_URL!,
        headers() {
          return headers;
        },
        fetch: (url, options) => {
          return fetch(url, {
            ...options,
            credentials: "include",
          });
        },
      }),
    ],
  });
Enter fullscreen mode Exit fullscreen mode

Since this middleware runs in edge runtime (e.g. in Vercel server if hosted with Vercel) but not in the browser, cookies will not sent in request and also cannot be sent in response. So we have to modify refreshAccessToken method in the backend to send access tokens in the response body instead of headers. Then we will set cookies in the middleware ourselves.

First, change refreshAccessToken procedure from protectedProcedure to publicProcedure since accessToken will be empty, and the server can't verify the request.

// backend/src/modules/auth/auth.routes.ts

  refreshAccessToken: publicProcedure.mutation(({ ctx }) => // <--- here
    new AuthController().refreshAccessTokenHandler(ctx)
  ),
Enter fullscreen mode Exit fullscreen mode

Now, modify refreshAccessTokenHandler to send accessToken in the response body.

// backend/src/modules/auth/auth.controller.ts

  async refreshAccessTokenHandler(ctx: Context) { // <--- Context change
    const cookies = new Cookies(ctx.req, ctx.res, {
      secure: process.env.NODE_ENV === "production",
    });

    const refreshToken = cookies.get("refreshToken");
    if (!refreshToken) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "Refresh token is required",
      });
    }

    const accessToken = await super.refreshAccessToken(refreshToken);

    return { success: true, accessToken }; // <--- here
  }
Enter fullscreen mode Exit fullscreen mode

Switch to frontend and implement refreshing access token inside middleware:

async function refreshAccessToken(
  request: NextRequest,
  response: NextResponse,
  refreshToken: string
) {
  try {
    const client = createTRPCServerClient({
      Cookie: `refreshToken=${refreshToken}`,
    });

    const { accessToken } = await client.auth.refreshAccessToken.mutate();

    response.cookies.set("accessToken", accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      path: "/",
    });

    response.cookies.set("logged_in", "true", {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      path: "/",
    });

    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}
Enter fullscreen mode Exit fullscreen mode

Use it in middleware:

import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createTRPCServerClient } from "./utils/trpc";

/**
 * Middleware function to handle authentication and redirection based on user login status and URL path.
 *
 * @param {NextRequest} request - The incoming request object.
 * @returns {Promise<NextResponse>} - The response object after processing the middleware logic.
 *
 * This middleware performs the following checks:
 * 1. If the user is logged in and trying to access authentication-related pages (login or register),
 *    it redirects them to the root URL ("/").
 * 2. If the user does not have a valid refresh token and is trying to access a page that requires authentication,
 *    it redirects them to the login page ("/login").
 * 3. If the user is not logged in but has a refresh token and is trying to access a page that requires authentication,
 *    it attempts to refresh the access token.
 */
export default async function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const pathname = request.nextUrl.pathname;
  const loggedIn = request.cookies.get("logged_in");
  const refreshToken = request.cookies.get("refreshToken")?.value;

  const authUrls = new Set(["/login", "/register"]);

  if (loggedIn && authUrls.has(pathname)) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  if (!refreshToken && !authUrls.has(pathname)) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  if (!loggedIn && refreshToken && !authUrls.has(pathname)) {
    await refreshAccessToken(request, response, refreshToken);
  }

  return response;
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

async function refreshAccessToken(
  request: NextRequest,
  response: NextResponse,
  refreshToken: string
) {
  try {
    const client = createTRPCServerClient({
      Cookie: `refreshToken=${refreshToken}`,
    });

    const { accessToken } = await client.auth.refreshAccessToken.mutate();

    response.cookies.set("accessToken", accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      path: "/",
    });

    response.cookies.set("logged_in", "true", {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      path: "/",
    });

    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}
Enter fullscreen mode Exit fullscreen mode

I've added comments to the code to make things clearer.


That's it! It's that easy.

Let me know if you have any doubts.

Follow for more amazing content in this series 🎸.

Socials

Top comments (0)