This article will teach you how to add JSON Web Token (JWT) Authentication to a tRPC API built with PostgreSQL, Prisma, Express, Node.js, and Redis.

When we build full-stack applications with TypeScript, we end up developing different strategies to share the static types between the frontend and backend, this approach works for a few genius developers.

Those who fall into the vast majority of people either end up increasing the complexity of their project or wasting productive hours writing unnecessary code.

Up until now, GraphQL has been the dominant library used by developers to build type-safety full-stack applications.

GraphQL was a game changer for most developers since it came with more flexibility and control.

However, the flexibility GraphQL brought to developers came at a price in the form of extra complexity and the need to learn the query language.

Due to the complexities of GraphQL, frameworks, libraries, and services started popping up to streamline the development process, and provide better patterns.

Recently, tRPC was introduced to help developers statically type their API endpoints and directly share the types between the client and server.

tRPC is lightweight, has zero dependencies, doesn’t rely on code generation, leaves a tiny client-side footprint, and has adaptors for Fastify/Express/Next.js.

tRPC API with React.js, Express, Prisma, and PostgreSQL Series:

  1. How to Setup tRPC API with Prisma, PostgreSQL, Node & React
  2. tRPC API with PostgreSQL, Prisma, Redis, & Node.js: JWT Authentication

Related Articles:

tRPC API with Postgres, Prisma & Node.js JWT Authentication

Prerequisites

Before we begin:

  • Basic knowledge of Node.js, and Express will be helpful.
  • An intermediate understanding of Prisma, and how ORMs and relational databases work will be highly beneficial.
  • Have Docker and Node.js installed

Setting up the Project

We will be using Express, Prisma, PostgreSQL, and tRPC along with the Express adaptor to implement the authentication flow.

Also, we will be using PostgreSQL as the primary database and Redis to store the authenticated user’s session. Despite implementing the authentication with JSON Web Tokens, we need to include Redis to add an extra layer of security.

Follow the Project Setup tutorial to set up the tRPC project with PostgreSQL, Express and Prisma before continuing with this article.

Creating the Database Models with Prisma

Prisma is an open-source ORM (Object Relational Mapping) for Node.js and TypeScript.

At the time of writing this article, Prisma supports PostgreSQL, MySQL, SQLite, SQL Server, MongoDB, and CockroachDB – making it the dominant ORM for any kind of project.

Before we start creating the database models, change the directory into the packages/client folder and install the following dependencies:


yarn add -D prisma && yarn add @prisma/client

Next, run the Prisma init command with an optional --datasource-provider parameter to set the type of database.

By default, the init command creates a PostgreSQL database, however, you can use the --datasource-provider parameter to change the database type.


npx prisma init --datasource-provider postgresql

The above command will create a prisma folder and either create a new .env file or update an existing one.

The newly-created packages/server/prisma/schema.prisma file will contain the configurations to connect to the database and the models needed to generate the database tables.

With Prisma, we define all our schemas or models in the packages/server/prisma/schema.prisma file.

Let’s create a User model and provide it with all the required attributes:

packages/server/prisma/schema.prisma


generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}


model User{
  @@map(name: "users")

  id String  @id @default(uuid())
  name String  @db.VarChar(255)
  email String @unique
  photo String? @default("default.png")
  verified Boolean? @default(false) 
  
  password String
  role RoleEnumType? @default(user)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  provider String?
}

enum RoleEnumType {
  user
  admin
}

Next, open the packages/server/.env file and update the dummy DATABASE_URL added by Prisma with the database credentials.

packages/server/.env


NODE_ENV=development

ORIGIN=http://127.0.0.1:3000

DATABASE_URL="postgresql://postgres:password123@localhost:6500/trpc_prisma?schema=public"

Database Migration with Prisma

Before we can run the database migration command, you need to have a running instance of a PostgreSQL database.

Follow the Project Setup tutorial to set up the tRPC project with PostgreSQL, Express, Docker, and Prisma before continuing with this article.

Now add the following scripts to the packages/server/package.json file:


{
"scripts": {
    "start": "ts-node-dev --respawn --transpile-only src/app.ts",
    "db:migrate": "npx prisma migrate dev --name user-entity --create-only && yarn prisma generate",
    "db:push": "npx prisma db push"
  },
}

  • db:migrate – this script will run the Prisma migrate command to create a new migration file and also generate the TypeScript types based on the models defined in the schema.prisma file.
  • db:push – this script will run the Prisma db command to push the changes to the database and keep the database in sync with the Prisma schema.

Open the terminal and run the following commands to generate the migration file and push the schema to the database.


yarn db:migrate && yarn db:push

If you are familiar with pgAdmin, you can log in with the database credentials to see the table added by Prisma.

checking the prisma model attributes in postgresql using pgadmin

Creating Schemas with Zod

By default, tRPC has support for schema validation libraries like Yup, Superstruct, Zod, and myzod. Also, it gives you the freedom to use your custom validators.

Run this command to install Zod:


yarn add zod

Next, create a packages/server/src/schemas/user.schema.ts file with the following schema definitions.

packages/server/src/schema/user.schema.ts


import { object, string, TypeOf } from 'zod';

export const createUserSchema = object({
  name: string({ required_error: 'Name is required' }),
  email: string({ required_error: 'Email is required' }).email('Invalid email'),
  photo: string({ required_error: 'Photo is required' }),
  password: string({ required_error: 'Password is required' })
    .min(8, 'Password must be more than 8 characters')
    .max(32, 'Password must be less than 32 characters'),
  passwordConfirm: string({ required_error: 'Please confirm your password' }),
}).refine((data) => data.password === data.passwordConfirm, {
  path: ['passwordConfirm'],
  message: 'Passwords do not match',
});

export const loginUserSchema = object({
  email: string({ required_error: 'Email is required' }).email(
    'Invalid email or password'
  ),
  password: string({ required_error: 'Password is required' }).min(
    8,
    'Invalid email or password'
  ),
});

export type CreateUserInput = TypeOf<typeof createUserSchema>;
export type LoginUserInput = TypeOf<typeof loginUserSchema>;

Creating Functions to Sign and Verify JWTs


yarn add jsonwebtoken && yarn add -D @types/jsonwebtoken

To learn more about JWT Authentication with React, check out React + Redux Toolkit: JWT Authentication and Authorization

To learn more about Refresh Tokens with React, check out React.js + Redux Toolkit: Refresh Tokens Authentication

Generating the JWT Private and Public Keys

Follow these steps to generate the private and public keys needed to sign and verify the JWT Tokens.

Alternatively, you can use the ones I provided in the packages/server/.env file.

Step 1: Go to the Private and Public Keys Generation Site, and click the “Generate New Keys” button to create the private and public keys.

Step 2: Copy the private key and visit the Base64 Encoding website to encode it in Base64.

Step 3: Copy the base64 encoded string and add it to the packages/server/.env file as ACCESS_TOKEN_PRIVATE_KEY .

Step 4: Go back to the Private and Public Keys Generation Site and copy the corresponding public key before encoding it in base64.

Add it to the packages/server/.env file as ACCESS_TOKEN_PUBLIC_KEY .

Step 5: Repeat the above steps for the refresh token private and public keys.

In the end, the packages/server/.env should look somewhat like this:

packages/server/.env


NODE_ENV=development

ORIGIN=http://127.0.0.1:3000

DATABASE_URL="postgresql://postgres:password123@localhost:6500/trpc_prisma?schema=public"

ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT1FJQkFBSkFVTWhaQjNucFJ6OEdrc0tneVZJcEZHMkJqZldHdElMWGNLUVFGMHZGbVZvOVVFcDhyOEVmCnI5T204azVTaXgrSi8rbXc0d08xVUlGb25rQTJFWnl6THdJREFRQUJBa0FDcVViOWp3K1hVRVU0S244L2dweGwKMXVHd3VvandnMnJ6aEFRZnNGaFhIKzlyQ1NWTmxNaEk0UWh4bWt3bHI2Y0NhUnFMUGg0Vk5lN3hKRDloWU5XcApBaUVBbXJ4TENiQXJJWDIvYkFETU1JdXljWFZKMnAwUk91S3FQOVBVeTB6ZG0zc0NJUUNGcGs5VDJKQ3NUVEhWCjErMWFVbk9JOFE3eUdNK1VQVGt6a200emNHcE8zUUloQUloOEU3Z2M4ejVjVzQ5WmVNSk5SbjI3VmdTRnpKL2oKTlFhTnc4SDdML0duQWlCTS9lUFJEMzg0WXpnRVV1SGZHSVNLTFNSSS8xWUZ0Y2RRR0ZqM3RSam8yUUlnS2t6ZwpVWFkwWjJRR1dqblFTdzdJRThaSDZuTHcydFUrci9LR0NZRzVKN3M9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZzd0RRWUpLb1pJaHZjTkFRRUJCUUFEU2dBd1J3SkFVTWhaQjNucFJ6OEdrc0tneVZJcEZHMkJqZldHdElMWApjS1FRRjB2Rm1WbzlVRXA4cjhFZnI5T204azVTaXgrSi8rbXc0d08xVUlGb25rQTJFWnl6THdJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t

REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT1FJQkFBSkFZOHRTUEZXMTk3bWgwcitCWUdLVTA4OFRPcDkrT2FObVNWQ1lMMTFhb05ZeEY1TSs1d0NSCnNDTnAxVEdHNW5zb215NW9QRitLajFsOGhjbmtUSUU2SndJREFRQUJBa0FVN2dLc1ZzbVlVQjJKWnRMS2xVSmoKZmUycGdPUG5VTWJXSDRvYmZQZlIvWGNteTdONkQyVXVQcnJ0MkdQVUpnNVJ4SG5NbVFpaDJkNHUwY3pqRDhpcApBaUVBcDFNaUtvY1BEWDJDU0lGN3c5SzVGWHlqMjIzQXJQcVJoUzNtL1dkVzVlVUNJUUNZcmxyeXRJOFkvODIzCkQ1ZTFHVExnbDlTcXN1UWdvaGF4ZCtKaXludGZHd0lnQ2xlK0xlakpTbWt1cTNLdGhzNDR1SlpLdnA2TElXWWYKcHA3T3YyMHExdTBDSVFDSy9lYWpuZ1hLLzB3NXcwTWJSUVpRK1VkTDRqRFZHRm5LVTFYUEUzOStVd0lnSEdLWgpjcDd2K3VyeG5kU05GK25MVEpZRG9abkMrKytteXRMaCtSUmU4dVU9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZzd0RRWUpLb1pJaHZjTkFRRUJCUUFEU2dBd1J3SkFZOHRTUEZXMTk3bWgwcitCWUdLVTA4OFRPcDkrT2FObQpTVkNZTDExYW9OWXhGNU0rNXdDUnNDTnAxVEdHNW5zb215NW9QRitLajFsOGhjbmtUSUU2SndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t

Next, let’s add the variables to the packages/server/src/config/default.ts file to help us provide the TypeScript types for the environment variables.

packages/server/src/config/default.ts


import path from 'path';
require('dotenv').config({ path: path.join(__dirname, '../../.env') });

const customConfig: {
  port: number;
  accessTokenExpiresIn: number;
  refreshTokenExpiresIn: number;
  origin: string;
  dbUri: string;
  accessTokenPrivateKey: string;
  accessTokenPublicKey: string;
  refreshTokenPrivateKey: string;
  refreshTokenPublicKey: string;
  redisCacheExpiresIn: number;
} = {
  port: 8000,
  accessTokenExpiresIn: 15,
  refreshTokenExpiresIn: 60,
  redisCacheExpiresIn: 60,
  origin: 'http://localhost:3000',

  dbUri: process.env.DATABASE_URL as string,
  accessTokenPrivateKey: process.env.ACCESS_TOKEN_PRIVATE_KEY as string,
  accessTokenPublicKey: process.env.ACCESS_TOKEN_PUBLIC_KEY as string,
  refreshTokenPrivateKey: process.env.REFRESH_TOKEN_PRIVATE_KEY as string,
  refreshTokenPublicKey: process.env.REFRESH_TOKEN_PUBLIC_KEY as string,
};

export default customConfig;

With the above configurations, let’s create a packages/server/src/utils/jwt.ts file and add these two functions to sign and verify the JSON Web Tokens.

Signing the JWT Tokens

packages/server/src/utils/jwt.ts


import jwt, { SignOptions } from 'jsonwebtoken';
import customConfig from '../config/default';

export const signJwt = (
  payload: Object,
  key: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
  options: SignOptions = {}
) => {
  const privateKey = Buffer.from(customConfig[key], 'base64').toString('ascii');
  return jwt.sign(payload, privateKey, {
    ...(options && options),
    algorithm: 'RS256',
  });
};

Verifying the JWT Tokens

packages/server/src/utils/jwt.ts


export const verifyJwt = <T>(
  token: string,
  key: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
): T | null => {
  try {
    const publicKey = Buffer.from(customConfig[key], 'base64').toString(
      'ascii'
    );
    return jwt.verify(token, publicKey) as T;
  } catch (error) {
    console.log(error);
    return null;
  }
};

Creating the Database Services with Prisma

One of the best practices of RESTful API architecture is to separate the business and application logic.

In layman’s terms, you should create services to interact with the database. That means the controllers should not be allowed to communicate directly with the database.

The rule is to push most of the business logic to the models and services, leaving us with thinner controllers and larger models.

Now let’s create a packages/server/src/services/user.service.ts file with the following services.

packages/server/src/services/user.service.ts


import { Prisma, User } from '@prisma/client';
import customConfig from '../config/default';
import redisClient from '../utils/connectRedis';
import { signJwt } from '../utils/jwt';
import { prisma } from '../utils/prisma';

export const createUser = async (input: Prisma.UserCreateInput) => {
  return (await prisma.user.create({
    data: input,
  })) as User;
};

export const findUser = async (
  where: Partial<Prisma.UserCreateInput>,
  select?: Prisma.UserSelect
) => {
  return (await prisma.user.findFirst({
    where,
    select,
  })) as User;
};

export const findUniqueUser = async (
  where: Prisma.UserWhereUniqueInput,
  select?: Prisma.UserSelect
) => {
  return (await prisma.user.findUnique({
    where,
    select,
  })) as User;
};

export const updateUser = async (
  where: Partial<Prisma.UserWhereUniqueInput>,
  data: Prisma.UserUpdateInput,
  select?: Prisma.UserSelect
) => {
  return (await prisma.user.update({ where, data, select })) as User;
};

export const signTokens = async (user: Prisma.UserCreateInput) => {
  // 1. Create Session
  redisClient.set(`${user.id}`, JSON.stringify(user), {
    EX: customConfig.redisCacheExpiresIn * 60,
  });

  // 2. Create Access and Refresh tokens
  const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
    expiresIn: `${customConfig.accessTokenExpiresIn}m`,
  });

  const refresh_token = signJwt({ sub: user.id }, 'refreshTokenPrivateKey', {
    expiresIn: `${customConfig.refreshTokenExpiresIn}m`,
  });

  return { access_token, refresh_token };
};

Creating the Authentication Handlers

We are now ready to create the authentication handlers to:

  1. Create a new user
  2. Request a new access token
  3. Sign the user into the account
  4. Log the user out from the account

To begin, let’s define the cookie options. Create a packages/server/src/controllers/auth.controller.ts file with the following imports and cookie options.

packages/server/src/controllers/auth.controller.ts


import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs';
import { CookieOptions } from 'express';
import { Context } from '../app';
import customConfig from '../config/default';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import {
  createUser,
  findUniqueUser,
  findUser,
  signTokens,
} from '../services/user.service';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';
// [...] Imports

// [...] Cookie Options
const cookieOptions: CookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
};

const accessTokenCookieOptions: CookieOptions = {
  ...cookieOptions,
  expires: new Date(Date.now() + customConfig.accessTokenExpiresIn * 60 * 1000),
};

const refreshTokenCookieOptions: CookieOptions = {
  ...cookieOptions,
  expires: new Date(
    Date.now() + customConfig.refreshTokenExpiresIn * 60 * 1000
  ),
};

How to Register a New User

packages/server/src/controllers/auth.controller.ts


// [...] Register User Handler
export const registerHandler = async ({
  input,
}: {
  input: CreateUserInput;
}) => {
  try {
    const hashedPassword = await bcrypt.hash(input.password, 12);
    const user = await createUser({
      email: input.email.toLowerCase(),
      name: input.name,
      password: hashedPassword,
      photo: input.photo,
      provider: 'local',
    });

    return {
      status: 'success',
      data: {
        user,
      },
    };
  } catch (err: any) {
    if (err instanceof Prisma.PrismaClientKnownRequestError) {
      if (err.code === 'P2002') {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'Email already exists',
        });
      }
    }
    throw err;
  }
};

Let’s evaluate the above code:

First, we hashed the plain-text password provided by the user and called the createUser() service to add the user to the PostgreSQL database.

Also, since we added a unique constraint to the email column, Prisma will return an error with a “P2002” code indicating that the email already exists in the PostgreSQL database.

How to Sign in the User

Now that we are able to create a user, let’s define the tRPC handler to sign in the registered user.

packages/server/src/controllers/auth.controller.ts


// [...] Register User Handler

// [...] Login User Handler
export const loginHandler = async ({
  input,
  ctx,
}: {
  input: LoginUserInput;
  ctx: Context;
}) => {
  try {
    // Get the user from the collection
    const user = await findUser({ email: input.email.toLowerCase() });

    // Check if user exist and password is correct
    if (!user || !(await bcrypt.compare(input.password, user.password))) {
      throw new TRPCError({
        code: 'BAD_REQUEST',
        message: 'Invalid email or password',
      });
    }

    // Create the Access and refresh Tokens
    const { access_token, refresh_token } = await signTokens(user);

    // Send Access Token in Cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send Access Token
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

Quite a lot happening in the above, let’s break it down:

  • First, we evoked the findUser() service to check if that email exists in the PostgreSQL database.
  • Next, we validated the plain-text password with the hashed one in the database.
  • Lastly, we generated the access and refresh tokens and sent them to the tRPC client as HTTPOnly cookies.

How to Refresh the Access Token

packages/server/src/controllers/auth.controller.ts


// [...] Register User Handler

// [...] Login User Handler

// [...] Refresh Access Token Handler
export const refreshAccessTokenHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    // Get the refresh token from cookie
    const refresh_token = ctx.req.cookies?.refresh_token as string;

    const message = 'Could not refresh access token';
    if (!refresh_token) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Validate the Refresh token
    const decoded = verifyJwt<{ sub: string }>(
      refresh_token,
      'refreshTokenPublicKey'
    );

    if (!decoded) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user has a valid session
    const session = await redisClient.get(decoded.sub);
    if (!session) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user exist
    const user = await findUniqueUser({ id: JSON.parse(session).id });

    if (!user) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Sign new access token
    const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
      expiresIn: `${customConfig.accessTokenExpiresIn}m`,
    });

    // Send the access token as cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send response
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

How to SignOut the User

packages/server/src/controllers/auth.controller.ts


// [...] Register User Handler

// [...] Login User Handler

// [...] Refresh Access Token Handler

// [...] Logout User Handler
const logout = ({ ctx }: { ctx: Context }) => {
  ctx.res.cookie('access_token', '', { maxAge: -1 });
  ctx.res.cookie('refresh_token', '', { maxAge: -1 });
  ctx.res.cookie('logged_in', '', {
    maxAge: -1,
  });
};

export const logoutHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    const user = ctx.user;
    await redisClient.del(user?.id as string);
    logout({ ctx });
    return { status: 'success' };
  } catch (err: any) {
    throw err;
  }
};

Complete Code for the Authentication Handlers

packages/server/src/controllers/auth.controller.ts


import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs';
import { CookieOptions } from 'express';
import { Context } from '../app';
import customConfig from '../config/default';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import {
  createUser,
  findUniqueUser,
  findUser,
  signTokens,
} from '../services/user.service';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';
// [...] Imports

// [...] Cookie Options
const cookieOptions: CookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
};

const accessTokenCookieOptions: CookieOptions = {
  ...cookieOptions,
  expires: new Date(Date.now() + customConfig.accessTokenExpiresIn * 60 * 1000),
};

const refreshTokenCookieOptions: CookieOptions = {
  ...cookieOptions,
  expires: new Date(
    Date.now() + customConfig.refreshTokenExpiresIn * 60 * 1000
  ),
};

// [...] Register User Handler
export const registerHandler = async ({
  input,
}: {
  input: CreateUserInput;
}) => {
  try {
    const hashedPassword = await bcrypt.hash(input.password, 12);
    const user = await createUser({
      email: input.email.toLowerCase(),
      name: input.name,
      password: hashedPassword,
      photo: input.photo,
      provider: 'local',
    });

    return {
      status: 'success',
      data: {
        user,
      },
    };
  } catch (err: any) {
   if (err instanceof Prisma.PrismaClientKnownRequestError) {
      if (err.code === 'P2002') {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'Email already exists',
        });
      }
    }
    throw err;
  }
};

// [...] Login User Handler
export const loginHandler = async ({
  input,
  ctx,
}: {
  input: LoginUserInput;
  ctx: Context;
}) => {
  try {
    // Get the user from the collection
    const user = await findUser({ email: input.email.toLowerCase() });

    // Check if user exist and password is correct
    if (!user || !(await bcrypt.compare(input.password, user.password))) {
      throw new TRPCError({
        code: 'BAD_REQUEST',
        message: 'Invalid email or password',
      });
    }

    // Create the Access and refresh Tokens
    const { access_token, refresh_token } = await signTokens(user);

    // Send Access Token in Cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send Access Token
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

// [...] Refresh Access Token Handler
export const refreshAccessTokenHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    // Get the refresh token from cookie
    const refresh_token = ctx.req.cookies?.refresh_token as string;

    const message = 'Could not refresh access token';
    if (!refresh_token) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Validate the Refresh token
    const decoded = verifyJwt<{ sub: string }>(
      refresh_token,
      'refreshTokenPublicKey'
    );

    if (!decoded) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user has a valid session
    const session = await redisClient.get(decoded.sub);
    if (!session) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user exist
    const user = await findUniqueUser({ id: JSON.parse(session).id });

    if (!user) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Sign new access token
    const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
      expiresIn: `${customConfig.accessTokenExpiresIn}m`,
    });

    // Send the access token as cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send response
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

// [...] Logout User Handler
const logout = ({ ctx }: { ctx: Context }) => {
  ctx.res.cookie('access_token', '', { maxAge: -1 });
  ctx.res.cookie('refresh_token', '', { maxAge: -1 });
  ctx.res.cookie('logged_in', '', {
    maxAge: -1,
  });
};

export const logoutHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    const user = ctx.user;
    await redisClient.del(user?.id as string);
    logout({ ctx });
    return { status: 'success' };
  } catch (err: any) {
    throw err;
  }
};

Creating a User Handler

packages/server/src/controllers/user.controller.ts


import { TRPCError } from '@trpc/server';
import type { Context } from '../app';

export const getMeHandler = ({ ctx }: { ctx: Context }) => {
  try {
    const user = ctx.user;
    return {
      status: 'success',
      data: {
        user,
      },
    };
  } catch (err: any) {
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: err.message,
    });
  }
};

Creating the Authentication Guard

packages/server/src/middleware/deserializeUser.ts


import { TRPCError } from '@trpc/server';
import { findUniqueUser } from '../services/user.service';
import { Request, Response } from 'express';
import redisClient from '../utils/connectRedis';
import { verifyJwt } from '../utils/jwt';

export const deserializeUser = async ({
  req,
  res,
}: {
  req: Request;
  res: Response;
}) => {
  try {
    // Get the token
    let access_token;
    if (
      req.headers.authorization &&
      req.headers.authorization.startsWith('Bearer')
    ) {
      access_token = req.headers.authorization.split(' ')[1];
    } else if (req.cookies?.access_token) {
      access_token = req.cookies.access_token;
    }

    const notAuthenticated = {
      req,
      res,
      user: null,
    };

    if (!access_token) {
      return notAuthenticated;
    }

    // Validate Access Token
    const decoded = verifyJwt<{ sub: string }>(
      access_token,
      'accessTokenPublicKey'
    );

    if (!decoded) {
      return notAuthenticated;
    }

    // Check if user has a valid session
    const session = await redisClient.get(decoded.sub);

    if (!session) {
      return notAuthenticated;
    }

    // Check if user still exist
    const user = await findUniqueUser({ id: JSON.parse(session).id });

    if (!user) {
      return notAuthenticated;
    }

    return {
      req,
      res,
      user,
    };
  } catch (err: any) {
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: err.message,
    });
  }
};

Connecting the Routers to the App

Before adding the tRPC endpoints, install the following dependencies:


yarn add cookie-parser && yarn add -D @types/cookie-parser morgan @types/morgan

  • cookie-parser – for parsing cookies in the request headers
  • morgan – for logging the tRPC requests

Now open the packages/server/src/app.ts file and add the following tRPC endpoints.

In addition, add the cookie parser to the middleware stack to enable Express to parse the cookies.

packages/server/src/app.ts


import path from "path";
import dotenv from "dotenv";
import express from "express";
import morgan from "morgan";
import cors from "cors";
import { inferAsyncReturnType, initTRPC, TRPCError } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";
import redisClient from "./utils/connectRedis";
import customConfig from "./config/default";
import connectDB from "./utils/prisma";
import { deserializeUser } from "./middleware/deserializeUser";
import { createUserSchema, loginUserSchema } from "./schema/user.schema";
import {
  loginHandler,
  logoutHandler,
  refreshAccessTokenHandler,
  registerHandler,
} from "./controllers/auth.controller";
import { getMeHandler } from "./controllers/user.controller";
import cookieParser from "cookie-parser";

dotenv.config({ path: path.join(__dirname, "./.env") });

const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) =>
  deserializeUser({ req, res });

export type Context = inferAsyncReturnType<typeof createContext>;

export const t = initTRPC.context<Context>().create();

const authRouter = t.router({
  registerUser: t.procedure
    .input(createUserSchema)
    .mutation(({ input }) => registerHandler({ input })),
  loginUser: t.procedure
    .input(loginUserSchema)
    .mutation(({ input, ctx }) => loginHandler({ input, ctx })),
  logoutUser: t.procedure.mutation(({ ctx }) => logoutHandler({ ctx })),
  refreshToken: t.procedure.query(({ ctx }) =>
    refreshAccessTokenHandler({ ctx })
  ),
});

const isAuthorized = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to access this resource",
    });
  }
  return next();
});

const isAuthorizedProcedure = t.procedure.use(isAuthorized);

const userRouter = t.router({
  sayHello: t.procedure.query(async () => {
    const message = await redisClient.get("tRPC");
    return { message };
  }),
  getMe: isAuthorizedProcedure.query(({ ctx }) => getMeHandler({ ctx })),
});

const appRouter = t.mergeRouters(authRouter, userRouter);

export type AppRouter = typeof appRouter;

const app = express();
if (process.env.NODE_ENV !== "production") app.use(morgan("dev"));

app.use(cookieParser());
app.use(
  cors({
    origin: [customConfig.origin, "http://localhost:3000"],
    credentials: true,
  })
);
app.use(
  "/api/trpc",
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

const port = customConfig.port;
app.listen(port, () => {
  console.log(`🚀 Server listening on port ${port}`);

  // CONNECT DB
  connectDB();
});

Conclusion

Congratulations on reaching the end. In this article, you learned how to implement access and refresh tokens in a tRPC API using Node.js, Express, PostgreSQL, Prisma, Docker-compose, and Redis.

tRPC, Prisma, PostgreSQL, & Express Source Code

Check out the: