Better Auth Integration
Use Better Auth sessions in oRPC context and protect procedures with typed middleware.
Use your Better Auth instance with oRPC’s context and middleware. No extra package is needed.
Resolve the Session in Middleware
The Request Headers Plugin exposes request headers as context.reqHeaders. The middleware loads the session from them and rejects unauthenticated calls. Public procedures use the base directly. Each protected call performs its own lookup, including every sub-request of a batch.
import type { RequestHeadersHandlerPluginContext } from '@orpc/server/plugins'
import { ORPCError, os } from '@orpc/server'
interface ServerContext extends RequestHeadersHandlerPluginContext {}
const base = os.$context<ServerContext>()
const requireSession = base.middleware(async ({ context, next }) => {
const session = await auth.api.getSession({
headers: context.reqHeaders ?? new Headers(),
})
if (!session) {
throw new ORPCError('UNAUTHORIZED')
}
return next({ context: { session } })
})
const protectedProcedure = base.use(requireSession)
const router = {
ping: base.handler(() => ({ message: 'pong' })),
me: protectedProcedure.handler(({ context }) => ({
id: context.session.user.id,
name: context.session.user.name,
})),
}
context.session is Better Auth’s full result with user and session. Its type is inferred from your auth instance, so additional fields stay available.
Only a missing session becomes UNAUTHORIZED. Other errors from getSession propagate to oRPC’s error handling.
reqHeaders is undefined without the plugin, such as in server-side calls. The empty Headers fallback carries no session cookie, so getSession returns null and protected calls return UNAUTHORIZED. Pass reqHeaders in the initial context to authenticate such calls.
Lazily Load and Share the Session
If your server resolves the session itself, pass a lazy getter into the initial context instead of the session. The lookup runs at most once per request and only when a procedure asks for it. This includes batch requests, where every sub-request shares the getter. The same getter can also serve the rest of your request handling.
import { ORPCError, os } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'
type Session = Awaited<ReturnType<typeof auth.api.getSession>>
function once<T>(fn: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | undefined
return () => {
promise ??= fn()
return promise
}
}
const base = os.$context<{ getSession: () => Promise<Session> }>()
const requireSession = base.middleware(async ({ context, next }) => {
const session = await context.getSession()
if (!session) {
throw new ORPCError('UNAUTHORIZED')
}
return next({ context: { session } })
})
const protectedProcedure = base.use(requireSession)
const router = {
greeting: base.handler(async ({ context }) => {
const session = await context.getSession()
return { message: `Hello, ${session?.user.name ?? 'guest'}` }
}),
me: protectedProcedure.handler(({ context }) => ({
id: context.session.user.id,
name: context.session.user.name,
})),
}
const handler = new RPCHandler(router)
export async function fetch(request: Request): Promise<Response> {
const getSession = once(() => auth.api.getSession({ headers: request.headers }))
const { matched, response } = await handler.handle(request, {
prefix: '/rpc',
context: { getSession },
})
return matched ? response : new Response('Not Found', { status: 404 })
}