Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export default withMermaid(defineConfig({
{ text: 'Define Contract', link: '/docs/contract-first/define-contract' },
{ text: 'Implement Contract', link: '/docs/contract-first/implement-contract' },
{ text: 'Router to Contract', link: '/docs/contract-first/router-to-contract' },
{ text: 'Protected Procedures', link: '/docs/contract-first/protected-procedures' },
],
},
{
Expand Down
96 changes: 96 additions & 0 deletions apps/content/docs/contract-first/protected-procedures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
title: Protected Procedures
Copy link
Member

@unnoq unnoq Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to clarify that type-safe errors are an optional pattern, not a requirement. Because of that, I don’t think the “Protected Procedure” title is suitable here, or alternatively, the content is missing the approach for handling a normal ORPCError.

I think we should roll back the changes made in the playground and only mention type-safe errors as an optional approach, rather than treating them as the default.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense.

Should I proceed with reverting the playground changes and focus on the documentation changes by adding the normal ORPCError approach as a default and adding the type-safe errors as an option?

description: Learn how to create protected procedures with authentication middleware
---

# Protected Procedures

You can build protected procedures by defining [errors](/docs/error-handling) in your contract, chaining authentication [middleware](/docs/middleware) on top of an implementer, and then using it in your procedures.

## Define Error in Contracts

Define errors in your contracts using the `.errors()` method. This allows the client to know exactly what errors a procedure can throw.

```ts twoslash
import { oc } from '@orpc/contract'
import * as z from 'zod'
// ---cut---
export const contract = oc
.errors({
UNAUTHORIZED: {
message: 'User is not authenticated',
},
})
.input(
z.object({
id: z.number()
})
)
.output(
z.object({
id: z.number(),
name: z.string()
})
)
```

## Type-Safe Errors in Middleware

Middlewares can also throw type-safe errors. However, since not every procedure defined in your contract may contain the error used in the middleware, you must use **type narrowing** to make the error accessible from the `errors` object.

```ts
export const authMiddleware = implement(contract)
.$context<{ user?: { id: string, email: string } }>()
.middleware(({ context, next, errors }) => {
// Type narrowing: Check if UNAUTHORIZED error is defined in the contract
if (!('UNAUTHORIZED' in errors)) {
throw new Error('Contract is missing UNAUTHORIZED error')
}

if (!context.user) {
// Throw type-safe error
throw errors.UNAUTHORIZED()
}

return next({
context: {
user: context.user,
},
})
})
```

## Setting Up Protected Procedures

Start by creating a public implementer with your contract. Then extend it with an authentication middleware to create the protected one:

```ts
export const pub = implement(contract)
.use(dbProviderMiddleware)

export const authed = pub
.use(authMiddleware)
```

:::info
By using `pub.use(authMiddleware)`, the protected implementer inherits all middleware from the public implementer and adds authentication on top.
:::

## Protect the Procedure

Now use `pub` for public procedures and `authed` for protected ones in your router:

```ts
// Public procedure - no authentication required
export const listPlanets = pub.planet.list
.handler(async ({ input, context }) => {
return context.db.planets.list(input.limit, input.cursor)
})

// Protected procedure - requires authentication
export const createPlanet = authed.planet.create
.handler(async ({ input, context }) => {
// context.user is guaranteed to exist here due to authMiddleware
return context.db.planets.create(input, context.user)
})
```
5 changes: 5 additions & 0 deletions playgrounds/contract-first/src/contract/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ export const me = oc
summary: 'Get the current user',
tags: ['Authentication'],
})
.errors({
UNAUTHORIZED: {
message: 'User is not authenticated',
},
})
.output(UserSchema)
4 changes: 4 additions & 0 deletions playgrounds/contract-first/src/contract/planet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const createPlanet = oc
summary: 'Create a planet',
tags: ['Planets'],
})
.errors({
UNAUTHORIZED: { message: 'User is not authenticated' },
})
.input(NewPlanetSchema)
.output(PlanetSchema)

Expand All @@ -49,6 +52,7 @@ export const updatePlanet = oc
tags: ['Planets'],
})
.errors({
UNAUTHORIZED: { message: 'User is not authenticated' },
NOT_FOUND: {
message: 'Planet not found',
data: z.object({ id: UpdatePlanetSchema.shape.id }),
Expand Down
30 changes: 30 additions & 0 deletions playgrounds/contract-first/src/middlewares/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { implement } from '@orpc/server'
import { contract } from '../contract'
import type { UserSchema } from '../schemas/user'
import type { z } from 'zod'

export interface AuthContext {
user?: z.infer<typeof UserSchema>
}

export const authMiddleware = implement(contract)
.$context<AuthContext>()
.middleware(({ context, next, errors }) => {
/**
* Type narrowing check for error constructor access.
* Required when not all procedures in the contract define this error.
*/
if (!('UNAUTHORIZED' in errors)) {
throw new Error('Contract is missing UNAUTHORIZED error')
}

if (!context.user) {
throw errors.UNAUTHORIZED()
}

return next({
context: {
user: context.user,
},
})
})
23 changes: 4 additions & 19 deletions playgrounds/contract-first/src/orpc.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,10 @@
import type { z } from 'zod'
import type { UserSchema } from './schemas/user'
import { implement, ORPCError } from '@orpc/server'
import { implement } from '@orpc/server'
import { dbProviderMiddleware } from './middlewares/db'
import { contract } from './contract'

export interface ORPCContext {
user?: z.infer<typeof UserSchema>
}
import { authMiddleware } from './middlewares/auth'

export const pub = implement(contract)
.$context<ORPCContext>()
.use(dbProviderMiddleware)

export const authed = pub.use(({ context, next }) => {
if (!context.user) {
throw new ORPCError('UNAUTHORIZED')
}

return next({
context: {
user: context.user,
},
})
})
export const authed = pub
.use(authMiddleware)
2 changes: 1 addition & 1 deletion playgrounds/contract-first/src/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ export const signin = pub.auth.signin
})

export const me = authed.auth.me
.handler(async ({ input, context }) => {
.handler(async ({ context }) => {
return context.user
})
Loading