DEV Community

Cover image for Handle Clerk Authentication for back-end in TRPC + Next.js
Harit Joshi
Harit Joshi

Posted on

Handle Clerk Authentication for back-end in TRPC + Next.js

Introduction:

Hey fellow devs, hope you are rocking 💥. From the title, you know what the post is about because if you guys have used Clerk then you know the unclarity in their docs for the manual JWT verification for the backend API especially dealing with auth tokens in trpc along with refreshing the tokens as clerk currentUser() and auth() function doesn't return the current session values in trpc environment.

This article will clear all those doubts with code snippets so that you won't face the pitfalls like I did.

No mistakes

Pre-requisites:

  • Intermediate at Next.js (with AppRouter).

  • Set up Clerk account.

  • Good knowledge about TRPC.

  • Knowledge about JS, React, Next.js AppRouter, and RSC's.

Setup:

Install Next.js, Clerk, and TRPC.

Note: For the clerk, I am using the old version with authMiddleware() function provided in the clerk's old version.

bunx create-next-app@latest | bun add @clerk/nextjs@^4.29.5 @clerk/themes@^1.7.9 @trpc/server @trpc/client @trpc/next jose axios superjson
Enter fullscreen mode Exit fullscreen mode

This will create a new next.js project and during the setup please select the AppRouter version of next js otherwise things will break.

Then hit

bun run dev
Enter fullscreen mode Exit fullscreen mode


to start the next.js dev server.

Now once that's done go to your clerk's dashboard and get the secret and publishable keys, JWKS URL, JWT template name, and backend URL.

You can check out their docs how to grab them is simple.

Also to get the publishable key check this link out from the clerk docs.

Then go to your next js app in vs code and in your .env file add these variables and write their values accordingly.

CLERK_SECRET_KEY = 'YOUR CLERK SECRET'
CLERK_JWKS_URL = 'FROM CLERK DASHBOARD'
CLERK_BACKEND_URL = 'FROM CLERK DASHBOARD'
CLERK_JWT_TEMPLATE = 'FROM CLERK DASHBOARD'
Enter fullscreen mode Exit fullscreen mode

With all that done the folder structure of your app should be like this:

Our folder structure will be as follows:

  ./src
├── app
│   ├── api
│   │   └── trpc
│   │       └── [...trpc]
│   │           └── route.ts
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── middleware.ts
└── server
    ├── context.ts
    ├── root.ts
    ├── routers
    │   ├── router.ts
    ├── index.ts
    └── utils
Enter fullscreen mode Exit fullscreen mode

Now in the next section, we will set up clerk with next js and get the auth in the client side up and running.

Clerk setup:

With a base empty project in hand lets add the magic of clerk to have auth running and it's as simple as adding components in our code just we are used to and taking full leverage of what clerk has to offer.

Inside the layout.tsx file inside the ./app directory add the following piece of code:

import type { Metadata } from "next";
import { DM_Sans, Modak } from "next/font/google";
import "./globals.css";
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";

const font = DM_Sans({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Demo app",
  description: "Wiring up auth with clerk",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={font.className}>
        <ClerkProvider appearance={{ baseTheme: dark }}>
            {children}
        </ClerkProvider>
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

So by adding this, we want to manage auth in the root level of our application and by adding this provider as <ClerkProvider appearance={{ baseTheme: dark }}> our server components will get access to session info managed by the clerk anywhere in our application.

With appearance={{ baseTheme: dark }} we configure to get a dark variant of the clerk dialogue screen for oAuth. You can choose light or system values based on your needs.

Now in your middleware.ts file, we will add logic to control what routes should be public and some pre and post-auth hooks that the clerk offers us out of the box.

Note: The method authMiddleware() function used in the middleware.ts file is deprecated but since you can have more granular control with this method I used this one.

So add the following code in middleware.ts file:

import { authMiddleware } from "@clerk/nextjs";
import { NextResponse } from "next/server";

export default authMiddleware({
  publicRoutes: ["/", /api/trpc/(.*)"],
  async beforeAuth(auth, req) {},
  async afterAuth(auth, req) {
    // you can do anything here based on your needs.
    return NextResponse.next();
  },
});

export const config = {
  matcher: ["/(.*?trpc.*?|(?!static|.*\\..*|_next|favicon.ico).*)", "/"],
};

Enter fullscreen mode Exit fullscreen mode

With this, you made only "/" and any route in "/api/trpc/" as public routes, and any other route will be treated as a private route with no auth access and with

export const config = {
  matcher: ["/(.*?trpc.*?|(?!static|.*\\..*|_next|favicon.ico).*)", "/"],
};

Enter fullscreen mode Exit fullscreen mode

this code next.js will only run the middleware function for routes having /trpc somewhere in the URL.

Rest all other requests will simply pass and next.js won't run the middleware.

Woah!! Now you are just one step away from adding the final piece to the puzzle and adding authentication in your app.

Bare with me

Go to your page.tsx file and now we will add a simple piece of code that shows how easy it is to wire up auth.

Then paste the following code:


import React from 'react'
import { currentUser, SignIn } from '@clerk/nextjs'

const page = () => {
  const user = await currentUser();
  return (
   <>
    <SignIn />
    {user? <span>Auth works</span> : <span> Unauthenticated!!</span>}
   </>
  )
}

export default page
Enter fullscreen mode Exit fullscreen mode

Now when users click the sign in button they will be redirected to the clerk's sign-in page (which you can customize on their dashboard).

See how easy it is to get user info after sign-in. This code only works on the server side so no leakage of sensitive stuff and now based on the user you can hide or show stuff in your UI.

You can also use <SignedIn> <SignedOut> components of the clerk to show UI before or after authentication. So neat isn't it?

Things become challenging when working with trpc as currentUser() function returns undefined and I don't know why this happens so now I will show you a way that seems a little complex but trust me I have put pieces together for you so you don't waste your time figuring what to do in that situation.

I am assuming you know how to structure trpc with next.js (AppRouter) and how it works. So I won't waste your time in explaining that.

TRPC setup:

Okay so go to server/context.ts to make a context which is kinda like a state which can be used in our trpc routes to share data.

Paste this code in that file.

import "server-only";

import { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { RequestCookie } from "next/dist/compiled/@edge-runtime/cookies";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
import { User } from "@clerk/nextjs/server";
import { TRPCError } from "@trpc/server";
import { ClerkJWTStrategy } from "./utils/ClerkJWTStrategy";

interface ClerkAuthContext {
  clerkAuth?: { user?: User };
  clerkSessionCookie?: RequestCookie;
  strategy?: ClerkJWTStrategy;
  isRefreshing?: boolean;
}

interface CreateInnerContext {
  clerkSessionCookie?: RequestCookie;
  req?: Request;
  resHeaders?: Headers;
}

interface InnerContextReturnType extends ClerkAuthContext {
  db: SomeDBClient;
  resHeaders?: Headers;
}

export const createContextInner = async ({
  clerkSessionCookie,
  req,
  resHeaders,
}: CreateInnerContext): Promise<InnerContextReturnType> => {
  const sessToken = clerkSessionCookie?.value!;
  const jwksUrl = process.env.CLERK_JWKS_URL!;
  const bearerToken = process.env.CLERK_SECRET_KEY!;
  const clerkBEUrL = process.env.CLERK_BACKEND_URL!;

  const strategy = new ClerkJWTStrategy(sessToken, jwksUrl, clerkBEUrL, redis);

  if (req?.url.includes("refresh"))
    return {
      clerkSessionCookie,
      db,
      strategy,
      clerkAuth: { user: undefined },
      isRefreshing: true,
      resHeaders,
    };

  try {
    const payload = await strategy.startPayloadVerification();

    // you can use caching here to cache user by using any in-memory database

    return {
      clerkSessionCookie,
      db,
      clerkAuth: { user },
      strategy,
      resHeaders,
    };
  } catch (e) {
    const error = e as TRPCError;
    if (error.name === "JWTExpired") {
      throw new TRPCError({
        code: "PARSE_ERROR",
        cause: "Expired",
        message: "Token is expired need refresh",
      });
    }

    throw new TRPCError({
      code: error.code,
      cause: error.cause,
      message: error.message,
    });
  }
};

export const createContext = async (opts: FetchCreateContextFnOptions) => {
  const clerkSessionCookie = cookies().get("__session");
  const innerContext = await createContextInner({
    clerkSessionCookie,
    req: opts.req,
    resHeaders: opts.resHeaders,
  });

  return {
    ...innerContext,
  };
};

export type Context = Awaited<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

We will handle this import import { ClerkJWTStrategy } from "./utils/ClerkJWTStrategy"; later.

First we define interfaces for our context objects. The ClerkAuthContext interface includes user information and session cookies, while the CreateInnerContext interface handles request and response headers.

Core Functions: The createContextInner function is responsible for verifying user sessions and fetching user data. It uses Clerk's JWT strategy to verify the session token and retrieve user data either from a cache (for optimization)or directly from Clerk's backend using JWKS endpoint which is the alternative to currentUser() call. Comprehensive error handling is included to manage JWT expiration and other authentication errors.

The createContext() function wraps createContextInner() by integrating request-specific data, such as cookies and headers, to return a complete context for TRPC resolvers.

Let's handle ClerkJWTStrategy which is a class to manage strategy to manually manage JWT tokens. Go into utils and create a file ClerkJWTStrategy.ts and paste the following code:

import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { TRPCError } from "@trpc/server";
import { User } from "@clerk/nextjs/server";
import { convertResponse } from "@/lib/snakeCaseToCamel";
import axios from "axios";
import clerk from "@clerk/clerk-sdk-node";

export class ClerkJWTStrategy {
  constructor(
    private sessToken: string,
    private jwksUrl: string,
    private backendUrl: string
  ) {
    if (!sessToken) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "No session token provided. Request denied",
      });
    }
  }

  private generateJWKSSet = () => createRemoteJWKSet(new URL(this.jwksUrl));

  checkSession = () => {
    if (!this.sessToken) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "No session token provided. Request denied",
      });
    }
  };

  startPayloadVerification = async () => {
    const { payload } = await jwtVerify(this.sessToken, this.generateJWKSSet());
    const currentTimestamp = new Date().getTime() / 1000;

    if (!payload) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "Payload verification failed",
      });
    } else if (payload?.exp && currentTimestamp > payload?.exp) {
      throw new TRPCError({
        code: "PARSE_ERROR",
        cause: "TOKEN EXPIRED",
        message: "Session token expired, refresh token",
      });
    }

    return payload;
  };

  getUser = async (payload: JWTPayload, token: string) => {
    const response = await axios.get<User[]>(`${this.backendUrl}/users`, {
      params: {
        user_id: payload.sub,
      },
      headers: {
        Authorization: `Bearer ${token}`,
      },
      withCredentials: true,
    });

    const user = convertResponse(response.data[0]);
    return user;
  };

  refreshToken = async (sid: string, template: string) => {
    const jwtToken = (await clerk.sessions.getToken(sid, template)) as string;
    return jwtToken;
  };
}

Enter fullscreen mode Exit fullscreen mode

We start by importing essential libraries and modules, such as jose for JWT verification, axios for HTTP requests, and Clerk's SDK for session management. Additionally, we import utilities to handle responses and error handling.

ClerkJWTStrategy class is the core of our authentication strategy. It takes a session token, JWKS URL, and backend URL as parameters. Upon initialization, it checks for the presence of a session token, throwing an unauthorized error if it's missing.

Methods:

generateJWKSSet(): Creates a JSON Web Key Set (JWKS) for verifying JWTs using the provided JWKS URL.

checkSession(): Ensures that a session token is present, throwing an error if it's not.

startPayloadVerification(): Verifies the session token's payload using the JWKS. It checks for the presence and expiration of the payload, throwing appropriate errors if verification fails or the token is expired.

getUser(): Retrieves user information based on the JWT payload. It sends a request to the Clerk's backend to fetch user data, which is then processed and returned.

refreshToken(): Retrieves a new JWT for a given session ID using Clerk's session management capabilities.

With that, our context is ready which was the difficult part and now we just simply create a trpc instance passing the context and then start working with the routers.

Instantiate TRPC and TRPC Router:

Now go to server/root.ts file and paste the following code:

import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { Context } from "./context";

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

export const {
  createCallerFactory,
  mergeRouters,
  router,
  procedure: publicProcedure,
} = t;

export const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => {

// import and pass db if you have one...
  const { db, clerkAuth, clerkSessionCookie, strategy, isRefreshing } = ctx;

  if (isRefreshing) {
    if (clerkSessionCookie)
      return next({
        ctx: { clerkSessionCookie, db, strategy },
      });

    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You are not allowed to access this route",
    });
  } 

  else if (!clerkAuth?.user)
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You are not allowed to access this route",
    });

  return next({
    ctx: { clerkSessionCookie, db, clerkAuth, strategy },
  });
});
Enter fullscreen mode Exit fullscreen mode

Using initTRPC(), we create a TRPC instance configured with superjson as the transformer. We define a custom errorFormatter to handle ZodError by flattening its structure for better error messages.

Next, we export key TRPC utilities including createCallerFactory, mergeRouters, router, and publicProcedure for defining public routes.

The protectedProcedure() is a middleware ensuring that only authenticated users can access certain routes. It checks the authentication context (clerkAuth, clerkSessionCookie, strategy, isRefreshing). If a user is refreshing their session, it proceeds with a minimal context. If the user is not authenticated, it throws an UNAUTHORIZED error.

This setup ensures secure and efficient handling of both public and protected routes in a TRPC server, with robust error formatting and user authentication.

Now let's create our first router which will just check whether user is authenticated or not.

So go to server/routers.router.ts and create a router.

import { router } from "../root";

const appRouter = router({
  test: router({
    privateRoute: protectedProcedure.query(async({ctx}) => "Hello"),,
});

export type AppRouter = typeof appRouter;
export default appRouter;
Enter fullscreen mode Exit fullscreen mode

Now this private procedure will only return "Hello" if user is authenticated otherwise TRPCError is thrown.

ctx: This is the context which holds our user and other data which needs to be shared between routes.

Let's now create an API client which we can use to call our API function.

Go to server/index.ts file and paste this code which will expose TRPC API client based on the needs.

import "server-only";

import { RequestCookie } from "next/dist/compiled/@edge-runtime/cookies";
import { createCallerFactory } from "./root";
import appRouter from "./routers/router";

// if you have a db...
import { db } from "@/lib/db";

export const createAPICaller = (cookie?: RequestCookie) => {
  const createCaller = createCallerFactory(appRouter);
  return createCaller({ clerkSessionCookie: cookie, db });
};
Enter fullscreen mode Exit fullscreen mode

also we need to make sure next.js understands TRPC requests and treat it as an api request so we also need to create a route handler.

Go to your app/api/trpc/[...trpc]/route.ts and paste the following code:

import appRouter from "@/server/routers/router";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createContext } from "@/server/context";


// serverless func
const handler = (req: Request) =>
  fetchRequestHandler({
    router: appRouter,
    endpoint: "/api/trpc",
    req,
    createContext,
    responseMeta(opts) {
      const { ctx, paths, errors, type, data } = opts;
      console.log("REQUEST TO PATHs:", paths);

      const allOk = errors.length === 0;
      const isQuery = type === "query";

      if (allOk && isQuery) {
        const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
        return {
          headers: {
            "cache-control": `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
          },
        };
      }

      return {};
    },

    onError({ error, path }) {
      // also logging service can be used to catch errors and 
         dev or prod
      console.error(`>>> tRPC Error on '${path}'`, error.message);
    },
    batching: {
      enabled: true,
    },
  });

export { handler as GET, handler as POST };
Enter fullscreen mode Exit fullscreen mode

Here the serverless handler function processes incoming requests using fetchRequestHandler. It specifies:

Router: Uses appRouter for routing.

Endpoint: Sets the API endpoint to "/api/trpc".

Context: Uses createContext to build the request context.

Response Metadata: A responseMeta() function sets cache control headers for successful query requests, enabling one-day stale-while-revalidate caching. Here you can also redirect the request based on query params or route segment.

Error Handling: The onError() function logs any errors encountered, specifying the error path and message. You can also use this as a global error logger and connect it to your logging service.

Batching: Enables request batching to optimize network performance.

The handler is exported for both GET and POST requests.

Now everything is in place and we can use the createAPICaller() function to get access to api client to make api requests.

Api Request:

Go to app/page.tsx file and do the following:

import React from 'react'
import { currentUser, SignIn } from '@clerk/nextjs'
import { createAPICaller } from "@/server";

const page = () => {
  const user = await currentUser();

  // get clerk session cookie key to get its value
  const api = createAPICaller(cookies().get("__session")); 

  console.log(await api.test.privateRoute())  // "Hello"
  return (
   <>
    <SignIn />
    {user? <span>Auth works</span> : <span> Unauthenticated!!</span>}
   </>
  )
}

export default page
Enter fullscreen mode Exit fullscreen mode

We now simply import the createAPICaller() pass the __session key and only this key as the clerk uses it to maintain the user's session.

And now the API variable can be used to call our public or private trpc routes and await api.test.privateRoute() should return a "Hello" string.

You can also test this in your browser by typing the URL as localhost:3000/api/trpc/test.privateRoute and this should return you an object with a message property with the value "Hello".

And congrats my friend that you have learned how to work with the clerk in a next.js (AppRouter) and TRPC environment.

Conclusion:

Well now I think at this point you're a pro at trpc but I haven't covered how to refresh the tokens I have added the code in the TRPC section I think you can figure it out on your own. Thanks for reading.

Happy Coding!

Top comments (0)