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:
- It protects sensitive data and operations from unauthorized access.
- It enables personalized experiences by identifying who is making each request.
- It provides a foundation for authorization, where you can restrict actions based on user roles.
- It integrates cleanly with tRPC's middleware system, allowing you to enforce auth at the procedure level.
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.