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:
- Authentication: Letting the user into the server (through login).
- 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:
ts1import { 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
:
ts1import { 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
:
ts1export 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:
shell1npx 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
bcryptjs
- to hash passwordjsonwebtoken
- to generate access and refresh tokenscookies
- 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 sideioredis
- 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
shell1yarn add bcryptjs jsonwebtoken cookies ioredis
Here's the flow of authentication:
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:
ts1import { 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:
shell1REDIS_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:
ts1import 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:
- Find the user in the db with email.
- Verify if the password is correct by comparing it against the password in the db using
bcrypt
(since we store hashed passwords). - If all good, generate access and refresh tokens, store refresh token along with user id in Redis and return tokens.
- Then in the user controller class, call this method and send the tokens in response as HTTP cookies.
ts1// 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
:
ts1// 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:
ts1import { 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
.
ts1import 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:
- Check if the user already exists.
- If not, hash the password and create a user.
ts1// 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...
ts1// auth.controller.ts 2 3... 4 async registerHandler(data: typeof users.$inferInsert) { 5 return await super.register(data); 6 } 7...
ts1// 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:
- Check if a refresh token exists in active sessions.
- Verify token.
- Check if the user still exists.
- If allis good, generate a new access token and send it as a cookie.
ts1// 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...
ts1// 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...
ts1// 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:
- Single session: Clear currently active session i.e. the device the user currently using and want to logout from.
ts1// 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...
ts1// auth.routes.ts 2 3... 4 logout: protectedProcedure.mutation(({ ctx }) => 5 new AuthController().logoutHandler(ctx) 6 ), 7...
- 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.)
ts1// 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...
ts1// 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.
- First, open
src/trpc.ts
and modifycreateContext
to check for authenticated requests.
Steps:
- Get
accessToken
from request headers. - Verify access token.
- Check if the user has an active session in Redis.
- Check if the user still exists in the db.
- Return
req
, andres
in the tRPC context. Anduser
object if it is an authenticated request.
ts1// 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.
- Now let's create a reusable tRPC procedure called
protectedProcedure
to protect some endpoints and a tRPC middleware calledisAuthenticted
.
ts1// 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.
ts1// 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
:
ts1... 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 outsuperjson
transformer intrpc.ts
.
ts1const t = initTRPC.context<Context>().create({ 2 // transformer: SuperJSON, 3});
- You cannot test queries. For this, just change
query
tomutation
. After testing, make sure to revert changes.
POST /auth.register
POST /auth.login
If you have Redis Insight downloaded, you can easily see your keys.
POST /auth.refreshAccessToken
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.
- 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 🚀.
Comments
Recommended
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...
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...