Implementing Cookie-Based JWT Authentication in a tRPC Backend
Rakesh Potnuru
written by Rakesh Potnuru10 min read

Implementing Cookie-Based JWT Authentication in a tRPC Backend

Published from Publish Studio

Authentication is an important part of a full-stack app. This is the only thing left before you can call yourself a full-stack developer or engineer or whatever. So, in this article, I will share how to add classic (email + password) authentication to a full-stack app using Next.js middleware for the front end and tRPC for the back end.

For the sake of learning I'm not going to use third-party auth solutions like Auth.js, Clerk, auth0, Supabase auth. But in real apps it's better to use an auth solution because they handle everything for you and they are more secure.

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

Let's say we want to allow others to use our product Finance Tracker (GitHub repo). Before giving them access to the product, we have to do something, so they can't access each other’s data and keep their info secure. This is where auth comes in.

To achieve this goal, we have to let them create an account in our server and verify every time someone makes a request like adding a transaction so that we add that transaction to that specific user account.

Two important terms to understand before we move on:

  1. Authentication: Letting the user into the server (through login).
  2. Authorization: Giving permission to the user to perform certain actions (e.g.: add transaction, view transactions).

The Concept of Access and Refresh Tokens

Access tokens, as the name implies used to access resources from server. They often set to expire within 10 minutes to 1 hour. While refresh tokens are used only to get new access token after previous one expires and they often are long-lived (mostly 7+ days).

Then why do we need refresh tokens and why not make access tokens long-lived?

The whole point of refresh tokens is to minimize the attack window. Since access tokens are sent frequently, they have higher risk of getting compromised (like man-in-the middle attacks) than refresh tokens. And as they are short-lived, they reduce damage.

But if you don't use refresh tokens, you have to ask the user to log in again and again which is a bad user experience.

Securing Backend

Before adding auth, we have to create a user model to create accounts and create a relation with transactions.

So, open backend/src/modules and create user module and user.schema.ts file. Then create basic user schema along with zod schema for insert operation:

ts
1import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; 2import { createInsertSchema } from "drizzle-zod"; 3 4export const users = pgTable("users", { 5 id: serial("id").primaryKey(), 6 email: text("email").notNull().unique(), 7 password: text("password").notNull(), 8 createdAt: timestamp("created_at").defaultNow(), 9 updatedAt: timestamp("updated_at") 10 .defaultNow() 11 .$onUpdate(() => new Date()), 12}); 13 14export const insertUserSchema = createInsertSchema(users).omit({ 15 id: true, 16 createdAt: true, 17 updatedAt: true, 18});

Next, create a relation between the transaction and the user. Since one user can have many transactions, let's create one-to-many a relation.

In user.schema.ts:

ts
1import { relations } from "drizzle-orm"; 2 3export const usersRelations = relations(users, ({ many }) => ({ 4// each user can have multiple transactions 5 transactions: many(transactions), 6}));

In transaction.schema.ts:

ts
1export const transactions = pgTable("transactions", { 2... 3 userId: integer("user_id") // <--- add userId 4 .references(() => users.id, { onDelete: "cascade" }) // <--- delete transaction when referenced user is deleted 5 .notNull(), 6... 7}); 8 9export const transactionsRelations = relations(transactions, ({ one }) => ({ 10// each transaction belongs to only one user 11 user: one(users, { 12 fields: [transactions.userId], 13 references: [users.id], 14 }), 15})); 16 17export const insertTransactionSchema = createInsertSchema(transactions).omit({ 18 ... 19 userId: true, // <--- Remove userId from zod schema because we will get that from auth context which will be explained later 20});

Now run migrations:

shell
1npx drizzle-kit push

You will get a warning (THIS ACTION WILL CAUSE DATA LOSS AND CANNOT BE REVERTED) because we are adding a required field user_id but we have some data from the previous tutorial. Since it's just a tutorial, select truncate data.

Alright, it's time to add authentication.

First, install

  1. bcryptjs - to hash password
  2. jsonwebtoken - to generate access and refresh tokens
  3. cookies - to store tokens in secure cookies and include them in response when logging in so they can be stored in the user's browser to keep them logged in from the client side
  4. ioredis - to store the user ID and refresh token in Redis to keep users logged in from the server side and identify them later when requesting resources or new access token
shell
1yarn add bcryptjs jsonwebtoken cookies ioredis

Here's the flow of authentication:

auth flow

Why store refresh tokens in Redis? Why not just store the user and then decode the user from the refresh token?

Let's say a user is logged in from two devices, if you only store user, you create a single session. When one refresh token is compromised and you want to invalidate that, you have to logout user from all devices.

But if you store refresh token, user can have multiple sessions and you can have fine-grained control over user sessions and avoid misuse of the server resources. And also let user know how many sessions they currently have and show session info like device and location. This is why you see helpful email notifications like "Your account has been accessed from a new ip".

If you don't want advanced session management then a single session with just user data is enough.

Set up Redis

Create src/utils/redis.ts file. Configure Redis and create a Redis client:

ts
1import { Redis } from "ioredis"; 2 3export const redis = new Redis(process.env.REDIS_URL!);

Make sure to add REDIS_URL to env. If using local Redis, the URL looks like this:

shell
1REDIS_URL=redis://localhost:6379

Creating, and verifying tokens

Create another module called auth. In there, create auth.service.ts. Here, we will write reusable functions and generate and verify tokens:

ts
1import jwt from "jsonwebtoken"; 2 3const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!; 4const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!; 5 6export default class AuthService { 7 createAccessToken(userId: number) { 8 const accessToken = jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { 9 expiresIn: "15m", 10 }); 11 12 return accessToken; 13 } 14 15 createRefreshToken(userId: number) { 16 const refreshToken = jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { 17 expiresIn: "7d", 18 }); 19 20 return refreshToken; 21 } 22 23 verifyAccessToken(accessToken: string) { 24 try { 25 const decoded = jwt.verify(accessToken, ACCESS_TOKEN_SECRET) as { 26 sub: string; 27 }; 28 29 return decoded.sub; 30 } catch (error) { 31 console.log(error); 32 33 return null; 34 } 35 } 36 37 verifyRefreshToken(refreshToken: string) { 38 try { 39 const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as { 40 sub: string; 41 }; 42 43 return decoded.sub; 44 } catch (error) { 45 console.log(error); 46 47 return null; 48 } 49 }

As you can see, we are creating tokens by signing them with a secret and then we use that same secret to verify them.

Open .env and create two new env variables - ACCESS_TOKEN_SECRET and REFRESH_TOKEN_SECRET. Secrets can be any strings but for real projects I recommend using RSA keys.

Implement login

Steps in login:

  1. Find the user in the db with email.
  2. Verify if the password is correct by comparing it against the password in the db using bcrypt (since we store hashed passwords).
  3. If all good, generate access and refresh tokens, store refresh token along with user id in Redis and return tokens.
  4. Then in the user controller class, call this method and send the tokens in response as HTTP cookies.
ts
1// user.service.ts 2import { db } from "../../utils/db"; 3import { redis } from "../../utils/redis"; 4import { users } from "../user/user.schema"; 5import bcrypt from "bcryptjs"; 6import { eq } from "drizzle-orm"; 7 8export default class AuthService { 9... 10 async login(data: typeof users.$inferInsert) { 11 const { email, password } = data; 12 13 try { 14 const user = ( 15 await db.select().from(users).where(eq(users.email, email)).limit(1) 16 )[0]; 17 18 if (!user) { 19 throw new TRPCError({ 20 code: "UNAUTHORIZED", 21 message: "Invalid email or password", 22 }); 23 } 24 25 const isPasswordCorrect = await bcrypt.compare(password, user.password); 26 27 if (!isPasswordCorrect) { 28 throw new TRPCError({ 29 code: "UNAUTHORIZED", 30 message: "Invalid email or password", 31 }); 32 } 33 34 const accessToken = this.createAccessToken(user.id); 35 const refreshToken = this.createRefreshToken(user.id); 36 37 // Store refresh token in redis to track active sessions 38 await redis.set( 39 `refresh_token:${refreshToken}`, 40 user.id, 41 "EX", 42 7 * 24 * 60 * 60 // 7 days 43 ); 44 45 // Store refresh token in redis set to track active sessions 46 await redis.sadd(`refresh_tokens:${user.id}`, refreshToken); 47 await redis.expire(`refresh_tokens:${user.id}`, 7 * 24 * 60 * 60); // 7 days 48 49 // Store user in redis to validate session 50 await redis.set( 51 `user:${user.id}`, 52 JSON.stringify(user), 53 "EX", 54 7 * 24 * 60 * 60 55 ); // 7 days 56 57 return { 58 accessToken, 59 refreshToken, 60 }; 61 } catch (error) { 62 console.log(error); 63 64 throw new TRPCError({ 65 code: "UNAUTHORIZED", 66 message: "Something went wrong", 67 }); 68 } 69 } 70... 71}

Create user.controller.ts:

ts
1// user.controller.ts 2 3import Cookies, { SetOption } from "cookies"; 4import { Context } from "../../trpc"; 5import { users } from "../user/user.schema"; 6import AuthService from "./auth.service"; 7 8const cookieOptions: SetOption = { 9 httpOnly: true, 10 secure: process.env.NODE_ENV === "production", 11 sameSite: "strict", 12 path: "/", 13}; 14 15const accessTokenCookieOptions: SetOption = { 16 ...cookieOptions, 17 maxAge: 15 * 60 * 1000, // 15 minutes 18}; 19 20const refreshTokenCookieOptions: SetOption = { 21 ...cookieOptions, 22 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days 23}; 24 25export default class AuthController extends AuthService { 26 async loginHandler(data: typeof users.$inferInsert, ctx: Context) { 27 const { accessToken, refreshToken } = await super.login(data); 28 29 const cookies = new Cookies(ctx.req, ctx.res, { 30 secure: process.env.NODE_ENV === "production", 31 }); 32 cookies.set("accessToken", accessToken, { ...accessTokenCookieOptions }); 33 cookies.set("refreshToken", refreshToken, { 34 ...refreshTokenCookieOptions, 35 }); 36 cookies.set("logged_in", "true", { ...accessTokenCookieOptions }); 37 38 return { success: true }; 39 } 40}

Create auth.routes.ts file:

ts
1import { publicProcedure, router } from "../../trpc"; 2import { insertUserSchema } from "../user/user.schema"; 3import AuthController from "./auth.controller"; 4 5const authRouter = router({ 6 login: publicProcedure 7 .input(insertUserSchema) 8 .mutation(({ input, ctx }) => 9 new AuthController().loginHandler(input, ctx) 10 ), 11}); 12 13export default authRouter;

Add this auth route group to src/routes.ts.

ts
1import authRouter from "./modules/auth/auth.routes"; 2 3const appRouter = router({ 4 ... 5 auth: authRouter, 6});

Implement register

Register is simple, we just have to create an account. (I'm going to cover email verification in a later article since a lot of products ask users to verify email after giving access to the platform as part of their marketing strategy.)

Steps:

  1. Check if the user already exists.
  2. If not, hash the password and create a user.
ts
1// auth.service.ts 2 3... 4 async register(data: typeof users.$inferInsert) { 5 try { 6 const { email, password } = data; 7 8 const user = ( 9 await db.select().from(users).where(eq(users.email, email)).limit(1) 10 )[0]; 11 if (user) { 12 throw new TRPCError({ 13 code: "CONFLICT", 14 message: 15 "This email is associated with an existing account. Please login instead.", 16 }); 17 } 18 19 const salt = await bcrypt.genSalt(12); 20 const hashedPassword = await bcrypt.hash(password, salt); 21 22 const newUser = await db 23 .insert(users) 24 .values({ 25 email, 26 password: hashedPassword, 27 }) 28 .returning(); 29 30 return { 31 success: true, 32 user: newUser, 33 }; 34 } catch (error) { 35 console.log(error); 36 37 throw new TRPCError({ 38 code: "INTERNAL_SERVER_ERROR", 39 message: "Something went wrong", 40 }); 41 } 42 } 43...
ts
1// auth.controller.ts 2 3... 4 async registerHandler(data: typeof users.$inferInsert) { 5 return await super.register(data); 6 } 7...
ts
1// auth.routes.ts 2 3... 4 register: publicProcedure 5 .input(insertUserSchema) 6 .mutation(({ input }) => new AuthController().registerHandler(input)), 7...

Implement access token refresh

To implement access token refresh:

  1. Check if a refresh token exists in active sessions.
  2. Verify token.
  3. Check if the user still exists.
  4. If allis good, generate a new access token and send it as a cookie.
ts
1// auth.service.ts 2 3... 4 async refreshAccessToken(refreshToken: string) { 5 try { 6 const isTokenExist = await redis.get(`refresh_token:${refreshToken}`); 7 if (!isTokenExist) { 8 throw new TRPCError({ 9 code: "UNAUTHORIZED", 10 message: "Invalid refresh token", 11 }); 12 } 13 14 const userId = await this.verifyRefreshToken(refreshToken); 15 if (!userId) { 16 throw new TRPCError({ 17 code: "UNAUTHORIZED", 18 message: "Invalid refresh token", 19 }); 20 } 21 22 const accessToken = this.createAccessToken(parseInt(userId)); 23 24 return accessToken; 25 } catch (error) { 26 console.log(error); 27 28 throw new TRPCError({ 29 code: "INTERNAL_SERVER_ERROR", 30 message: "Something went wrong", 31 }); 32 } 33 } 34...
ts
1// auth.controller.ts 2 3... 4 async refreshAccessTokenHandler(ctx: AuthenticatedContext) { 5 const cookies = new Cookies(ctx.req, ctx.res, { 6 secure: process.env.NODE_ENV === "production", 7 }); 8 9 const refreshToken = cookies.get("refreshToken"); 10 if (!refreshToken) { 11 throw new TRPCError({ 12 code: "UNAUTHORIZED", 13 message: "Refresh token is required", 14 }); 15 } 16 17 const accessToken = await super.refreshAccessToken(refreshToken); 18 cookies.set("accessToken", accessToken, { ...accessTokenCookieOptions }); 19 cookies.set("logged_in", "true", { ...accessTokenCookieOptions }); 20 21 return { success: true }; 22 } 23...
ts
1// auth.routes.ts 2 3... 4 refreshAccessToken: protectedProcedure.mutation(({ ctx }) => 5 new AuthController().refreshAccessTokenHandler(ctx) 6 ), 7...

Implement logout

Finally, let's implement logout endpoint. For this, all we have to do is clear Redis and cookies.

Two types of logouts:

  1. Single session: Clear currently active session i.e. the device the user currently using and want to logout from.
ts
1// auth.controller.ts 2 3... 4 async logoutHandler(ctx: AuthenticatedContext) { 5 const { req, res, user } = ctx; 6 7 try { 8 const cookies = new Cookies(req, res, { 9 secure: process.env.NODE_ENV === "production", 10 }); 11 const refreshToken = cookies.get("refreshToken"); 12 13 if (refreshToken) { 14 await redis.del(`refresh_token:${refreshToken}`); 15 await redis.srem(`refresh_tokens:${user.id}`, refreshToken); 16 } 17 18 cookies.set("accessToken", "", { ...accessTokenCookieOptions }); 19 cookies.set("refreshToken", "", { ...refreshTokenCookieOptions }); 20 cookies.set("logged_in", "false", { ...accessTokenCookieOptions }); 21 22 return { success: true }; 23 } catch (error) { 24 console.log(error); 25 26 throw new TRPCError({ 27 code: "INTERNAL_SERVER_ERROR", 28 message: "Something went wrong", 29 }); 30 } 31 } 32...
ts
1// auth.routes.ts 2 3... 4 logout: protectedProcedure.mutation(({ ctx }) => 5 new AuthController().logoutHandler(ctx) 6 ), 7...
  1. All sessions: This is often given as security feature if user notices some suspicious activity happening in their account. (I've seen a lot of products that don't give this feature and I truly hate them.)
ts
1// auth.controller.ts 2 3... 4 async logoutAllHandler(ctx: AuthenticatedContext) { 5 const { req, res, user } = ctx; 6 7 try { 8 const refreshTokens = await redis.smembers(`refresh_tokens:${user.id}`); 9 10 const pipeline = redis.pipeline(); 11 12 refreshTokens.forEach((refreshToken) => { 13 pipeline.del(`refresh_token:${refreshToken}`); 14 }); 15 pipeline.del(`refresh_tokens:${user.id}`); 16 pipeline.del(`user:${user.id}`); 17 18 await pipeline.exec(); 19 20 const cookies = new Cookies(req, res, { 21 secure: process.env.NODE_ENV === "production", 22 }); 23 cookies.set("accessToken", "", { ...accessTokenCookieOptions }); 24 cookies.set("refreshToken", "", { ...refreshTokenCookieOptions }); 25 cookies.set("logged_in", "false", { ...accessTokenCookieOptions }); 26 27 return { success: true }; 28 } catch (error) { 29 console.log(error); 30 31 throw new TRPCError({ 32 code: "INTERNAL_SERVER_ERROR", 33 message: "Something went wrong", 34 }); 35 } 36 } 37...
ts
1// auth.routes.ts 2 3... 4 logoutAll: protectedProcedure.mutation(({ ctx }) => 5 new AuthController().logoutAllHandler(ctx) 6 ), 7...

Set up auth middleware

Let's set up tRPC context and middleware and pass user data for authenticated requests, so we can use that to create protected procedures.

  1. First, open src/trpc.ts and modify createContext to check for authenticated requests.

Steps:

  1. Get accessToken from request headers.
  2. Verify access token.
  3. Check if the user has an active session in Redis.
  4. Check if the user still exists in the db.
  5. Return req, and res in the tRPC context. And user object if it is an authenticated request.
ts
1// src/trpc.ts 2import Cookies from "cookies"; 3 4export const createContext = async ({ 5 req, 6 res, 7}: CreateExpressContextOptions) => { 8 try { 9 const cookies = new Cookies(req, res); 10 const accessToken = cookies.get("accessToken"); 11 if (!accessToken) { 12 return { req, res }; 13 } 14 15 const userId = await new AuthService().verifyAccessToken(accessToken); 16 if (!userId) { 17 return { req, res }; 18 } 19 20 const session = await redis.get(`user:${userId}`); 21 if (!session) { 22 return { req, res }; 23 } 24 25 const user = ( 26 await db 27 .select() 28 .from(users) 29 .where(eq(users.id, parseInt(userId))) 30 .limit(1) 31 )[0]; 32 if (!user) { 33 return { req, res }; 34 } 35 36 return { 37 req, 38 res, 39 user, 40 }; 41 } catch (error) { 42 console.log(error); 43 44 throw new TRPCError({ 45 code: "INTERNAL_SERVER_ERROR", 46 message: "Something went wrong", 47 }); 48 } 49};

We can use this to identify which requests are authenticated.

  1. Now let's create a reusable tRPC procedure called protectedProcedure to protect some endpoints and a tRPC middleware called isAuthenticted.
ts
1// src/trpc.ts 2 3// Create another context type for protected routes, so ctx.user won't be null in authed requests 4export type AuthenticatedContext = Context & { 5 user: NonNullable<Context["user"]>; 6}; 7 8// Middleware to check if user is authenticated 9const isAuthenticated = t.middleware(({ ctx, next }) => { 10 if (!ctx.user) { 11 throw new TRPCError({ 12 code: "UNAUTHORIZED", 13 message: "You must be logged in to access this resource", 14 }); 15 } 16 17 return next({ 18 ctx: { 19 ...ctx, 20 user: ctx.user, 21 }, 22 }); 23}); 24 25// Using the middleware, create a protected procedure 26export const protectedProcedure = publicProcedure.use(isAuthenticated);

In a later article, we will create proProtectedProcedure for paid features when integrating a payment provider 👀. Follow for updates 🤫.

Now use this procedure in all transaction routes.

ts
1// src/modules/transaction/transaction.routes.ts 2 3const transactionRouter = router({ 4 create: protectedProcedure // <--- here 5 .input(insertUserSchema) 6 .mutation(({ input, ctx }) => 7 new TransactionController().createTransactionHandler(input, ctx) // pass context 8 ), 9 10 getAll: protectedProcedure.query(({ ctx }) => // <--- here 11 new TransactionController().getTransactionsHandler(ctx) // pass context 12 ), 13});

Then, open transaction.controller.ts and modify it like this to use the user id from ctx.user:

ts
1... 2 async createTransactionHandler( 3 data: Omit<typeof transactions.$inferInsert, "userId">, // <-- Omit userId as we get it from ctx, not input 4 ctx: AuthenticatedContext // <-- add ctx param 5 ) { 6 return await super.createTransaction({ 7 ...data, 8 userId: ctx.user.id, 9 }); 10 } 11 12 async getTransactionsHandler(ctx: AuthenticatedContext) { // <-- same here 13 return await super.getTransactions(ctx.user.id); 14 } 15...

Test Everything

Let's test our API using Postman to verify everything is working as expected. Testing tRPC API in Postman is a little different than traditional REST/Graphql APIs.

Limitations:

  • Cannot use superjson. So, before testing let's comment out superjson transformer in trpc.ts.
ts
1const t = initTRPC.context<Context>().create({ 2 // transformer: SuperJSON, 3});
  • You cannot test queries. For this, just change query to mutation. After testing, make sure to revert changes.
  1. POST /auth.register

register test

  1. POST /auth.login

login test

If you have Redis Insight downloaded, you can easily see your keys.

redis insight

  1. POST /auth.refreshAccessToken

refresh test

  1. POST /auth.logout

If you observe the cookies and headers tab, you can see tokens are empty and if you check Redis insight, the refresh token will be deleted. Same with /auth.logoutAll but this time, all refresh tokens belonging to the user including the user session will be deleted.

  1. Also, after logging in, try to test transaction routes.

That's it!


In the next article, I will share how to secure the Next.js front end.

Follow for updates 🚀.

Socials


Loved this post?

Comments

Recommended

Let's Build a Full-Stack App with tRPC and Next.js 14
Let's Build a Full-Stack App with tRPC and Next.js 14

Are you a typescript nerd looking to up your full-stack game? Then this guide is for you. The traditional way to share types of your API endpoints is to generat...

#nextjs
#expressjs
Setting Up Drizzle & Postgres with tRPC and Next.js App
Setting Up Drizzle & Postgres with tRPC and Next.js App

In this tutorial, let's learn how to connect a Postgres database to a tRPC express backend using Drizzle ORM. I have also created a simple frontend for our fina...

#drizzle
#postgresql

Interested in
working
with me?

Let's Connect

© 2021 - 2025 itsrakesh. v2.