← Back to DevBytes

tRPC Authentication: JWT, Sessions, and OAuth Integration

Introduction to tRPC Authentication

Authentication is one of the most critical aspects of any modern web application. When building APIs with tRPC, you have a powerful end-to-end type-safe framework, but securing those procedures requires thoughtful implementation. This tutorial covers three of the most common authentication strategies used with tRPC: JSON Web Tokens (JWT), session-based authentication, and OAuth integration.

By the end of this guide, you will understand how to authenticate tRPC procedures, protect routes, and integrate third-party identity providers while maintaining full type safety across your stack.

Why Authentication Matters in tRPC

tRPC allows you to build APIs where the client and server share types automatically. However, type safety does not equal security. Without proper authentication, any procedure you expose becomes publicly accessible. Authentication in tRPC matters because:

tRPC's context system is the backbone of authentication. Every request passes through a context creation function, where you can extract credentials, validate tokens, and attach the authenticated user to the context. Procedures then access this user information with full type inference.

Setting Up the tRPC Context

Before implementing any specific authentication strategy, you need a solid context foundation. The context is created on every request and carries information that procedures need, including the authenticated user.

Defining the Context Type

Start by defining what your context looks like. This typically includes an optional user object, since not all procedures require authentication.

import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { NextApiRequest, NextApiResponse } from 'next';

export interface User {
  id: string;
  email: string;
  name: string;
  role: 'user' | 'admin';
}

export async function createContext({
  req,
  res,
}: CreateNextContextOptions) {
  // Authentication will be resolved here
  const user = await getUserFromRequest(req);

  return {
    req,
    res,
    user,
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

The getUserFromRequest function is where your specific authentication strategy comes into play. Let's explore each strategy in detail.

JWT Authentication with tRPC

JSON Web Tokens are a stateless authentication mechanism. The server issues a signed token after login, and the client sends this token with each request. The server verifies the token signature and extracts the user information without needing to look up a session in a database.

Installing Dependencies

npm install jsonwebtoken
npm install -D @types/jsonwebtoken

Creating and Verifying JWTs

First, create utility functions for signing and verifying tokens. Always store your secret in an environment variable and never commit it to version control.

import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET as string;

if (!JWT_SECRET) {
  throw new Error('JWT_SECRET environment variable is required');
}

export interface JwtPayload {
  userId: string;
  email: string;
  role: 'user' | 'admin';
}

export function signToken(payload: JwtPayload): string {
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: '7d',
  });
}

export function verifyToken(token: string): JwtPayload | null {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
    return decoded;
  } catch (error) {
    return null;
  }
}

Extracting the Token from Requests

Clients typically send JWTs in the Authorization header using the Bearer scheme. Update your context creation to extract and verify the token.

import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { verifyToken, JwtPayload } from './jwt';

export async function getUserFromRequest(req: NextApiRequest): Promise<User | null> {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return null;
  }

  const token = authHeader.substring(7);
  const payload = verifyToken(token);

  if (!payload) {
    return null;
  }

  return {
    id: payload.userId,
    email: payload.email,
    name: '', // Fetch from database if needed
    role: payload.role,
  };
}

Creating a Login Procedure

Now create a public procedure that validates credentials and returns a JWT to the client.

import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
import { signToken } from '../utils/jwt';

export const authRouter = router({
  login: publicProcedure
    .input(z.object({
      email: z.string().email(),
      password: z.string().min(6),
    }))
    .mutation(async ({ input, ctx }) => {
      // Replace with your database lookup
      const user = await findUserByEmail(input.email);

      if (!user) {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Invalid email or password',
        });
      }

      const validPassword = await verifyPassword(input.password, user.passwordHash);

      if (!validPassword) {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Invalid email or password',
        });
      }

      const token = signToken({
        userId: user.id,
        email: user.email,
        role: user.role,
      });

      return {
        token,
        user: {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        },
      };
    }),
});

Protecting Procedures with JWT

Create a middleware that checks for an authenticated user and throws an error if none exists. This keeps your procedure definitions clean and consistent.

import { TRPCError } from '@trpc/server';
import { t } from './trpc';

export const protectedProcedure = t.procedure.use(({ 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, // Now non-null in downstream procedures
    },
  });
});

Use the protected procedure for any route that requires authentication:

export const userRouter = router({
  getProfile: protectedProcedure.query(({ ctx }) => {
    return ctx.user;
  }),

  updateProfile: protectedProcedure
    .input(z.object({
      name: z.string().min(1),
    }))
    .mutation(async ({ ctx, input }) => {
      return await updateUser(ctx.user.id, input);
    }),
});

Session-Based Authentication with tRPC

Session-based authentication stores session state on the server, typically in a database or in-memory store. The client receives a session ID in a cookie, and the server looks up the session on each request. This approach is more secure against certain attacks because the session can be invalidated server-side at any time.

Using NextAuth.js for Session Management

NextAuth.js (now Auth.js) is a popular library that handles session-based authentication with built-in support for databases, OAuth providers, and more. It pairs excellently with tRPC.

npm install next-auth @auth/prisma-adapter

Configuring NextAuth

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';

export const authOptions = {
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  providers: [
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email },
        });

        if (!user) return null;

        const valid = await bcrypt.compare(credentials.password, user.passwordHash);
        if (!valid) return null;

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },
};

export default NextAuth(authOptions);

Integrating NextAuth Sessions with tRPC Context

The key integration point is the context creation function. You call getServerSession to retrieve the current session and attach the user to the tRPC context.

import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';

export async function createContext(opts: CreateNextContextOptions) {
  const session = await getServerSession(opts.req, opts.res, authOptions);

  return {
    req: opts.req,
    res: opts.res,
    session,
    user: session?.user ?? null,
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

Creating a Session-Based Protected Procedure

import { TRPCError } from '@trpc/server';
import { t } from './trpc';

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'Authentication required',
    });
  }

  return next({
    ctx: {
      ...ctx,
      user: ctx.user,
    },
  });
});

export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (ctx.user.role !== 'admin') {
    throw new TRPCError({
      code: 'FORBIDDEN',
      message: 'Admin access required',
    });
  }

  return next({ ctx });
});

Client-Side Session Handling

On the client side, NextAuth manages the session cookie automatically. You do not need to manually attach tokens to tRPC requests when using cookies, since the browser sends them automatically.

import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCNext<AppRouter>({
  config({ ctx }) {
    return {
      links: [
        httpBatchLink({
          url: '/api/trpc',
          headers() {
            // Cookies are sent automatically by the browser
            return {};
          },
        }),
      ],
    };
  },
});

OAuth Integration with tRPC

OAuth allows users to authenticate using third-party providers like Google, GitHub, or Microsoft. This improves user experience and offloads credential management to trusted providers. OAuth integrates seamlessly with tRPC when combined with NextAuth or a similar library.

Adding OAuth Providers

Extend your NextAuth configuration to include OAuth providers. Each provider requires credentials obtained from its developer console.

import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';

export const authOptions = {
  // ... existing config
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    }),
    GitHubProvider({
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    }),
    // Keep credentials provider if you want email/password too
  ],
  callbacks: {
    async signIn({ user, account, profile }) {
      // Optionally link OAuth accounts to existing users
      if (account?.provider === 'google') {
        const existingUser = await prisma.user.findUnique({
          where: { email: user.email! },
        });

        if (!existingUser) {
          await prisma.user.create({
            data: {
              email: user.email!,
              name: user.name!,
              image: user.image,
              role: 'user',
              accounts: {
                create: {
                  provider: account.provider,
                  providerAccountId: account.providerAccountId,
                  access_token: account.access_token,
                  refresh_token: account.refresh_token,
                },
              },
            },
          });
        }
      }
      return true;
    },
    // ... jwt and session callbacks from before
  },
};

Adding OAuth Login Buttons on the Client

import { signIn, signOut, useSession } from 'next-auth/react';

export function AuthButtons() {
  const { data: session } = useSession();

  if (session) {
    return (
      <div>
        <p>Signed in as {session.user?.email}</p>
        <button onClick={() => signOut()}>Sign out</button>
      </div>
    );
  }

  return (
    <div>
      <button onClick={() => signIn('google')}>Sign in with Google</button>
      <button onClick={() => signIn('github')}>Sign in with GitHub</button>
    </div>
  );
}

Using OAuth-Authenticated User in tRPC Procedures

Once OAuth is configured, the tRPC context works exactly the same as with session-based auth. The user object is populated from the session, regardless of which provider was used.

export const accountRouter = router({
  // Returns linked OAuth accounts for the current user
  getLinkedAccounts: protectedProcedure.query(async ({ ctx }) => {
    const accounts = await prisma.account.findMany({
      where: { userId: ctx.user.id },
      select: {
        provider: true,
        providerAccountId: true,
      },
    });

    return accounts;
  }),

  // Unlink an OAuth provider
  unlinkAccount: protectedProcedure
    .input(z.object({ provider: z.string() }))
    .mutation(async ({ ctx, input }) => {
      await prisma.account.deleteMany({
        where: {
          userId: ctx.user.id,
          provider: input.provider,
        },
      });

      return { success: true };
    }),
});

Best Practices for tRPC Authentication

Always Validate Input with Zod

Never trust client input. Use Zod schemas on every procedure that accepts input, even authenticated ones. Authentication prevents unauthorized access, but input validation prevents malformed or malicious data from causing issues.

Use HTTPS in Production

Authentication tokens and session cookies are only secure when transmitted over HTTPS. Never deploy an application with authentication over plain HTTP, as credentials can be intercepted.

Implement Rate Limiting

Login endpoints are prime targets for brute-force attacks. Implement rate limiting on authentication procedures to prevent abuse. You can use a middleware or an external service to track and limit request rates per IP address.

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts per window
  message: 'Too many login attempts, please try again later',
});

// Apply to your login route handler

Separate Authentication from Authorization

Authentication answers "who are you?" while authorization answers "what can you do?". Keep these concerns separate. Use the protectedProcedure for authentication and create role-specific procedures like adminProcedure for authorization. This makes your security model explicit and easy to audit.

Refresh Tokens for Long-Lived Sessions

When using JWTs, avoid long expiration times. Instead, issue short-lived access tokens and longer-lived refresh tokens. The refresh token is used to obtain new access tokens without requiring the user to log in again. Store refresh tokens in the database so they can be revoked.

export function signAccessToken(payload: JwtPayload): string {
  return jwt.sign(payload, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
}

export function signRefreshToken(payload: JwtPayload): string {
  return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
}

// Store refresh token hash in database for revocation capability
export async function storeRefreshToken(userId: string, token: string) {
  const hashedToken = await bcrypt.hash(token, 10);
  await prisma.refreshToken.create({
    data: {
      userId,
      token: hashedToken,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });
}

Handle Errors Gracefully

Return generic error messages for authentication failures. Do not reveal whether an email exists in your system, as this information helps attackers. Always use messages like "Invalid email or password" rather than "User not found" or "Incorrect password."

Log Authentication Events

Maintain audit logs for security-critical events such as successful logins, failed login attempts, password changes, and OAuth account linking. These logs are invaluable for detecting suspicious activity and complying with security requirements.

Conclusion

Authentication in tRPC is flexible and powerful, thanks to the context system and middleware architecture. Whether you choose JWT for stateless simplicity, session-based auth for server-side control, or OAuth for third-party convenience, the integration patterns remain consistent: resolve the user in the context, enforce auth with middleware, and access the typed user object in your procedures. By following the best practices outlined in this tutorial—validating input, using HTTPS, implementing rate limiting, separating auth from authorization, and handling errors gracefully—you can build secure, production-ready applications that protect your users and their data while maintaining the type safety that makes tRPC such a compelling choice for modern web development.

— Ad —

Google AdSense will appear here after approval

← Back to all articles