@trpc/server
Version:
343 lines (275 loc) • 8.24 kB
Markdown
---
name: auth
description: >
Implement JWT/cookie authentication and authorization in tRPC using createContext
for user extraction, t.middleware with opts.next({ ctx }) for context narrowing to
non-null user, protectedProcedure base pattern, client-side Authorization headers
via httpBatchLink headers(), WebSocket connectionParams, and SSE auth via cookies
or EventSource polyfill custom headers.
type: composition
library: trpc
library_version: '11.15.1'
requires:
- server-setup
- middlewares
- client-setup
sources:
- www/docs/server/authorization.md
- www/docs/client/headers.md
- www/docs/client/links/httpSubscriptionLink.md
- www/docs/server/websockets.md
---
# tRPC — Auth
## Setup
```ts
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
export async function createContext({ req }: CreateHTTPContextOptions) {
async function getUserFromHeader() {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
const user = await verifyJwt(token); // your JWT verification
return user; // e.g. { id: string; name: string; role: string }
}
return null;
}
return { user: await getUserFromHeader() };
}
export type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(
async function isAuthed(opts) {
const { ctx } = opts;
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return opts.next({
ctx: {
user: ctx.user, // narrows user to non-null
},
});
},
);
export const router = t.router;
```
```ts
// client/trpc.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
let token = '';
export function setToken(t: string) {
token = t;
}
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
headers() {
return { Authorization: `Bearer ${token}` };
},
}),
],
});
```
## Core Patterns
### Context narrowing with auth middleware
```ts
import { initTRPC, TRPCError } from '@trpc/server';
type Context = { user: { id: string; role: string } | null };
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { user: ctx.user } });
});
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user || ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx: { user: ctx.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
export const adminProcedure = t.procedure.use(isAdmin);
```
### SSE subscription auth with EventSource polyfill
```ts
import {
createTRPCClient,
httpBatchLink,
httpSubscriptionLink,
splitLink,
} from '@trpc/client';
import { EventSourcePolyfill } from 'event-source-polyfill';
import type { AppRouter } from '../server/router';
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: httpSubscriptionLink({
url: 'http://localhost:3000/trpc',
EventSource: EventSourcePolyfill,
eventSourceOptions: async () => {
return {
headers: {
authorization: `Bearer ${getToken()}`,
},
};
},
}),
false: httpBatchLink({
url: 'http://localhost:3000/trpc',
headers() {
return { Authorization: `Bearer ${getToken()}` };
},
}),
}),
],
});
```
### WebSocket auth with connectionParams
```ts
// server/context.ts
import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws';
export const createContext = async (opts: CreateWSSContextFnOptions) => {
const token = opts.info.connectionParams?.token;
const user = token ? await verifyJwt(token) : null;
return { user };
};
```
```ts
// client/trpc.ts
import { createTRPCClient, createWSClient, wsLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const wsClient = createWSClient({
url: 'ws://localhost:3001',
connectionParams: async () => ({
token: getToken(),
}),
});
const trpc = createTRPCClient<AppRouter>({
links: [wsLink({ client: wsClient })],
});
```
### SSE auth with cookies (same domain)
```ts
import {
createTRPCClient,
httpBatchLink,
httpSubscriptionLink,
splitLink,
} from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: httpSubscriptionLink({
url: '/api/trpc',
eventSourceOptions() {
return { withCredentials: true };
},
}),
false: httpBatchLink({ url: '/api/trpc' }),
}),
],
});
```
## Common Mistakes
### HIGH Not narrowing user type in auth middleware
Wrong:
```ts
const authMiddleware = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next(); // user still nullable downstream
});
```
Correct:
```ts
const authMiddleware = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { user: ctx.user } }); // narrows to non-null
});
```
Without `opts.next({ ctx })`, downstream procedures still see `user` as `{ id: string } | null`, requiring redundant null checks.
Source: www/docs/server/authorization.md
### HIGH SSE auth via URL query params exposes tokens
Wrong:
```ts
httpSubscriptionLink({
url: 'http://localhost:3000/trpc',
connectionParams: async () => ({
token: 'my-secret-jwt',
}),
});
```
Correct:
```ts
import { EventSourcePolyfill } from 'event-source-polyfill';
httpSubscriptionLink({
url: 'http://localhost:3000/trpc',
EventSource: EventSourcePolyfill,
eventSourceOptions: async () => ({
headers: { authorization: 'Bearer my-secret-jwt' },
}),
});
```
`connectionParams` are serialized as URL query strings for SSE, exposing tokens in server logs and browser history. Use cookies for same-domain or custom headers via an EventSource polyfill instead.
Source: www/docs/client/links/httpSubscriptionLink.md
### MEDIUM Async headers causing stuck isFetching
Wrong:
```ts
httpBatchLink({
url: '/api/trpc',
async headers() {
const token = await refreshToken(); // can race
return { Authorization: `Bearer ${token}` };
},
});
```
Correct:
```ts
let cachedToken: string | null = null;
async function ensureToken() {
if (!cachedToken) cachedToken = await refreshToken();
return cachedToken;
}
httpBatchLink({
url: '/api/trpc',
async headers() {
return { Authorization: `Bearer ${await ensureToken()}` };
},
});
```
When the headers function is async (e.g., refreshing auth tokens), React Query's `isFetching` can get stuck permanently in certain race conditions.
Source: https://github.com/trpc/trpc/issues/7001
### HIGH Skipping auth or opening CORS too wide in prototypes
Wrong:
```ts
import cors from 'cors';
createHTTPServer({
middleware: cors(), // origin: '*' by default
router: appRouter,
createContext() {
return {};
}, // no auth
}).listen(3000);
```
Correct:
```ts
import cors from 'cors';
createHTTPServer({
middleware: cors({ origin: 'https://myapp.com' }),
router: appRouter,
createContext,
}).listen(3000);
```
Wildcard CORS and missing auth middleware are acceptable only during local development. Always restrict CORS origins and add auth before deploying.
Source: maintainer interview
## See Also
- **middlewares** -- context narrowing, `.use()`, `.concat()`, base procedure patterns
- **subscriptions** -- SSE and WebSocket transport setup for authenticated subscriptions
- **client-setup** -- `createTRPCClient`, link chain, `headers` option
- **links** -- `splitLink`, `httpSubscriptionLink`, `wsLink` configuration