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: import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; export const users = pgTable("users", { id: serial("id").primaryKey(), email: text("email").notNull().unique(), password: text("password").notNull(), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at") .defaultNow() .$onUpdate(() => new Date()), }); export const insertUserSchema = createInsertSchema(users).omit({ id: true, createdAt: true, updatedAt: true, }); 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: import { relations } from "drizzle-orm"; export const usersRelations = relations(users, ({ many }) => ({ // each user can have multiple transactions transactions: many(transactions), })); In transaction.schema.ts: export const transactions = pgTable("transactions", { ... userId: integer("user_id") // users.id, { onDelete: "cascade" }) // ({ // each transaction belongs to only one user user: one(users, { fields: [transactions.userId], references: [users.id], }), })); export const insertTransactionSchema = createInsertSchema(transactions).omit({ ... userId: true, // new AuthController().loginHandler(input, ctx) ), }); export default authRouter; Add this auth route group to src/routes.ts. import authRouter from "./modules/auth/auth.routes"; const appRouter = router({ ... auth: authRouter, }); 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. // auth.service.ts ... async register(data: typeof users.$inferInsert) { try { const { email, password } = data; const user = ( await db.select().from(users).where(eq(users.email, email)).limit(1) )[0]; if (user) { throw new TRPCError({ code: "CONFLICT", message: "This email is associated with an existing account. Please login instead.", }); } const salt = await bcrypt.genSalt(12); const hashedPassword = await bcrypt
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:
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
password: text("password").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date()),
});
export const insertUserSchema = createInsertSchema(users).omit({
id: true,
createdAt: true,
updatedAt: true,
});
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
:
import { relations } from "drizzle-orm";
export const usersRelations = relations(users, ({ many }) => ({
// each user can have multiple transactions
transactions: many(transactions),
}));
In transaction.schema.ts
:
export const transactions = pgTable("transactions", {
...
userId: integer("user_id") // <--- add userId
.references(() => users.id, { onDelete: "cascade" }) // <--- delete transaction when referenced user is deleted
.notNull(),
...
});
export const transactionsRelations = relations(transactions, ({ one }) => ({
// each transaction belongs to only one user
user: one(users, {
fields: [transactions.userId],
references: [users.id],
}),
}));
export const insertTransactionSchema = createInsertSchema(transactions).omit({
...
userId: true, // <--- Remove userId from zod schema because we will get that from auth context which will be explained later
});
Now run migrations:
npx 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 password -
jsonwebtoken
- to generate access and refresh tokens -
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 -
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
yarn 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:
import { Redis } from "ioredis";
export 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:
REDIS_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:
import jwt from "jsonwebtoken";
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;
export default class AuthService {
createAccessToken(userId: number) {
const accessToken = jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, {
expiresIn: "15m",
});
return accessToken;
}
createRefreshToken(userId: number) {
const refreshToken = jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, {
expiresIn: "7d",
});
return refreshToken;
}
verifyAccessToken(accessToken: string) {
try {
const decoded = jwt.verify(accessToken, ACCESS_TOKEN_SECRET) as {
sub: string;
};
return decoded.sub;
} catch (error) {
console.log(error);
return null;
}
}
verifyRefreshToken(refreshToken: string) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as {
sub: string;
};
return decoded.sub;
} catch (error) {
console.log(error);
return null;
}
}
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.
// user.service.ts
import { db } from "../../utils/db";
import { redis } from "../../utils/redis";
import { users } from "../user/user.schema";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
export default class AuthService {
...
async login(data: typeof users.$inferInsert) {
const { email, password } = data;
try {
const user = (
await db.select().from(users).where(eq(users.email, email)).limit(1)
)[0];
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid email or password",
});
}
const isPasswordCorrect = await bcrypt.compare(password, user.password);
if (!isPasswordCorrect) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid email or password",
});
}
const accessToken = this.createAccessToken(user.id);
const refreshToken = this.createRefreshToken(user.id);
// Store refresh token in redis to track active sessions
await redis.set(
`refresh_token:${refreshToken}`,
user.id,
"EX",
7 * 24 * 60 * 60 // 7 days
);
// Store refresh token in redis set to track active sessions
await redis.sadd(`refresh_tokens:${user.id}`, refreshToken);
await redis.expire(`refresh_tokens:${user.id}`, 7 * 24 * 60 * 60); // 7 days
// Store user in redis to validate session
await redis.set(
`user:${user.id}`,
JSON.stringify(user),
"EX",
7 * 24 * 60 * 60
); // 7 days
return {
accessToken,
refreshToken,
};
} catch (error) {
console.log(error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Something went wrong",
});
}
}
...
}
Create user.controller.ts
:
// user.controller.ts
import Cookies, { SetOption } from "cookies";
import { Context } from "../../trpc";
import { users } from "../user/user.schema";
import AuthService from "./auth.service";
const cookieOptions: SetOption = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
};
const accessTokenCookieOptions: SetOption = {
...cookieOptions,
maxAge: 15 * 60 * 1000, // 15 minutes
};
const refreshTokenCookieOptions: SetOption = {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};
export default class AuthController extends AuthService {
async loginHandler(data: typeof users.$inferInsert, ctx: Context) {
const { accessToken, refreshToken } = await super.login(data);
const cookies = new Cookies(ctx.req, ctx.res, {
secure: process.env.NODE_ENV === "production",
});
cookies.set("accessToken", accessToken, { ...accessTokenCookieOptions });
cookies.set("refreshToken", refreshToken, {
...refreshTokenCookieOptions,
});
cookies.set("logged_in", "true", { ...accessTokenCookieOptions });
return { success: true };
}
}
Create auth.routes.ts
file:
import { publicProcedure, router } from "../../trpc";
import { insertUserSchema } from "../user/user.schema";
import AuthController from "./auth.controller";
const authRouter = router({
login: publicProcedure
.input(insertUserSchema)
.mutation(({ input, ctx }) =>
new AuthController().loginHandler(input, ctx)
),
});
export default authRouter;
Add this auth route group to src/routes.ts
.
import authRouter from "./modules/auth/auth.routes";
const appRouter = router({
...
auth: authRouter,
});
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.
// auth.service.ts
...
async register(data: typeof users.$inferInsert) {
try {
const { email, password } = data;
const user = (
await db.select().from(users).where(eq(users.email, email)).limit(1)
)[0];
if (user) {
throw new TRPCError({
code: "CONFLICT",
message:
"This email is associated with an existing account. Please login instead.",
});
}
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
const newUser = await db
.insert(users)
.values({
email,
password: hashedPassword,
})
.returning();
return {
success: true,
user: newUser,
};
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
}
...
// auth.controller.ts
...
async registerHandler(data: typeof users.$inferInsert) {
return await super.register(data);
}
...
// auth.routes.ts
...
register: publicProcedure
.input(insertUserSchema)
.mutation(({ input }) => new AuthController().registerHandler(input)),
...
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.
// auth.service.ts
...
async refreshAccessToken(refreshToken: string) {
try {
const isTokenExist = await redis.get(`refresh_token:${refreshToken}`);
if (!isTokenExist) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid refresh token",
});
}
const userId = await this.verifyRefreshToken(refreshToken);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid refresh token",
});
}
const accessToken = this.createAccessToken(parseInt(userId));
return accessToken;
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
}
...
// auth.controller.ts
...
async refreshAccessTokenHandler(ctx: AuthenticatedContext) {
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);
cookies.set("accessToken", accessToken, { ...accessTokenCookieOptions });
cookies.set("logged_in", "true", { ...accessTokenCookieOptions });
return { success: true };
}
...
// auth.routes.ts
...
refreshAccessToken: protectedProcedure.mutation(({ ctx }) =>
new AuthController().refreshAccessTokenHandler(ctx)
),
...
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.
// auth.controller.ts
...
async logoutHandler(ctx: AuthenticatedContext) {
const { req, res, user } = ctx;
try {
const cookies = new Cookies(req, res, {
secure: process.env.NODE_ENV === "production",
});
const refreshToken = cookies.get("refreshToken");
if (refreshToken) {
await redis.del(`refresh_token:${refreshToken}`);
await redis.srem(`refresh_tokens:${user.id}`, refreshToken);
}
cookies.set("accessToken", "", { ...accessTokenCookieOptions });
cookies.set("refreshToken", "", { ...refreshTokenCookieOptions });
cookies.set("logged_in", "false", { ...accessTokenCookieOptions });
return { success: true };
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
}
...
// auth.routes.ts
...
logout: protectedProcedure.mutation(({ ctx }) =>
new AuthController().logoutHandler(ctx)
),
...
- 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.)
// auth.controller.ts
...
async logoutAllHandler(ctx: AuthenticatedContext) {
const { req, res, user } = ctx;
try {
const refreshTokens = await redis.smembers(`refresh_tokens:${user.id}`);
const pipeline = redis.pipeline();
refreshTokens.forEach((refreshToken) => {
pipeline.del(`refresh_token:${refreshToken}`);
});
pipeline.del(`refresh_tokens:${user.id}`);
pipeline.del(`user:${user.id}`);
await pipeline.exec();
const cookies = new Cookies(req, res, {
secure: process.env.NODE_ENV === "production",
});
cookies.set("accessToken", "", { ...accessTokenCookieOptions });
cookies.set("refreshToken", "", { ...refreshTokenCookieOptions });
cookies.set("logged_in", "false", { ...accessTokenCookieOptions });
return { success: true };
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
}
...
// auth.routes.ts
...
logoutAll: protectedProcedure.mutation(({ ctx }) =>
new AuthController().logoutAllHandler(ctx)
),
...
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.
// src/trpc.ts
import Cookies from "cookies";
export const createContext = async ({
req,
res,
}: CreateExpressContextOptions) => {
try {
const cookies = new Cookies(req, res);
const accessToken = cookies.get("accessToken");
if (!accessToken) {
return { req, res };
}
const userId = await new AuthService().verifyAccessToken(accessToken);
if (!userId) {
return { req, res };
}
const session = await redis.get(`user:${userId}`);
if (!session) {
return { req, res };
}
const user = (
await db
.select()
.from(users)
.where(eq(users.id, parseInt(userId)))
.limit(1)
)[0];
if (!user) {
return { req, res };
}
return {
req,
res,
user,
};
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
};
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
.
// src/trpc.ts
// Create another context type for protected routes, so ctx.user won't be null in authed requests
export type AuthenticatedContext = Context & {
user: NonNullable<Context["user"]>;
};
// Middleware to check if user is authenticated
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to access this resource",
});
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
});
// Using the middleware, create a protected procedure
export const protectedProcedure = publicProcedure.use(isAuthenticated);
In a later article, we will create
proProtectedProcedure
for paid features when integrating a payment provider