
Powerful permission layer for tRPC applications
Create secure, type-safe APIs with intuitive rule-based authorization
Quick Start β’ Documentation β’ Examples β’ Contributing
If this tool helps you build better applications, please consider supporting its development:
Your sponsorship helps maintain and improve this project. Thank you! π
Get the latest stable version with full tRPC v11 support!
npm install trpc-shield
This version includes tRPC v11.x compatibility and context extension support - bringing full compatibility with the latest tRPC features. For specific version requirements, see the compatibility table below.
- π Rule-based permissions - Define authorization logic with intuitive, composable rules
- π tRPC v11 support - Full compatibility with the latest tRPC features
- π Context extension - Rules can extend context with authentication data
- π§© Logic operators - Combine rules with
and
,or
,not
,chain
, andrace
- π‘οΈ Secure by default - Prevents data leaks with fallback rules
- π TypeScript first - Full type safety and IntelliSense support
- π― Zero dependencies - Lightweight and fast
- π§ͺ Well tested - Comprehensive test coverage
# npm
npm install trpc-shield
# yarn
yarn add trpc-shield
# pnpm
pnpm add trpc-shield
import { initTRPC } from '@trpc/server';
import { rule, shield, and, or, not } from 'trpc-shield';
type Context = {
user?: { id: string; role: string; name: string };
token?: string;
};
// Create rules
const isAuthenticated = rule<Context>()(async (ctx) => {
return ctx.user !== null;
});
const isAdmin = rule<Context>()(async (ctx) => {
return ctx.user?.role === 'admin';
});
// Create permissions
const permissions = shield<Context>({
query: {
publicData: true, // Always allow
profile: isAuthenticated,
adminData: and(isAuthenticated, isAdmin),
},
mutation: {
updateProfile: isAuthenticated,
deleteUser: and(isAuthenticated, isAdmin),
},
});
// Apply to tRPC
const t = initTRPC.context<Context>().create();
const middleware = t.middleware(permissions);
const protectedProcedure = t.procedure.use(middleware);
tRPC Version | Shield Version | Status |
---|---|---|
v11.x | v1.0.0+ | β Recommended |
v10.x | v0.2.0 - v0.4.x | |
v9.x | v0.1.2 and below | β Deprecated |
- tRPC v11 Support - Full compatibility with latest tRPC features
- Context Extension - Rules can now extend context (see Context Extension)
- Improved TypeScript - Better type inference and safety
- Performance Optimizations - Faster rule evaluation
- Enhanced Testing - Comprehensive test coverage
Rules are the building blocks of your permission system. Each rule is an async function that returns:
true
- Allow accessfalse
- Deny accessError
- Deny with custom error{ ctx: {...} }
- Allow and extend context
const isOwner = rule<Context>()(async (ctx, type, path, input) => {
const resourceId = input.id;
const resource = await getResource(resourceId);
if (resource.ownerId !== ctx.user?.id) {
return new Error('You can only access your own resources');
}
return true;
});
Combine rules with powerful logic operators:
const permissions = shield<Context>({
query: {
// All rules must pass
sensitiveData: and(isAuthenticated, isAdmin, isEmailVerified),
// At least one rule must pass
moderatedContent: or(isAdmin, isModerator),
// Rule must fail
publicEndpoint: not(isInternalRequest),
// Execute rules in sequence until one passes
content: race(isOwner, isCollaborator, isPublicAccess),
// Execute rules in sequence, all must pass
secureAction: chain(isAuthenticated, isEmailVerified, hasPermission),
},
});
New in v1.0.0 - Rules can extend the tRPC context
Rules can return an object with a ctx
property to extend the context for subsequent middleware and procedures:
const withAuth = rule<Context>()(async (ctx) => {
// If user is already in context, just validate
if (ctx.user) {
return true;
}
// If we have a token, validate and extend context
if (ctx.token) {
try {
const user = await validateToken(ctx.token);
// Extend context with user data
return { ctx: { user } };
} catch {
return new Error('Invalid token');
}
}
return false;
});
// Usage
const authenticatedProcedure = t.procedure
.use(shield({ query: { profile: withAuth } }))
.query(({ ctx }) => {
// ctx.user is now available and properly typed!
return { message: `Hello ${ctx.user.name}!` };
});
Organize permissions for complex router structures:
const permissions = shield<Context>({
// Nested router permissions
user: {
query: {
profile: isAuthenticated,
list: and(isAuthenticated, isAdmin),
},
mutation: {
update: isOwner,
delete: and(isAuthenticated, or(isOwner, isAdmin)),
},
},
// Another namespace
posts: {
query: {
public: true,
drafts: isOwner,
},
mutation: {
create: isAuthenticated,
publish: and(isOwner, hasPublishPermission),
},
},
});
Customize shield behavior:
const permissions = shield<Context>(
{
query: {
data: isAuthenticated,
},
},
{
// Allow external errors to be thrown (default: false)
allowExternalErrors: true,
// Enable debug mode for development
debug: process.env.NODE_ENV === 'development',
// Default rule for undefined paths (default: allow)
fallbackRule: deny,
// Custom error message or Error instance
fallbackError: 'Access denied',
// or
fallbackError: new CustomError('Insufficient permissions'),
}
);
const permissions = shield<Context>({
mutation: {
deletePost: rule<Context>()(async (ctx, type, path, input) => {
const post = await getPost(input.id);
if (!post) {
return new Error('Post not found');
}
if (post.authorId !== ctx.user?.id && ctx.user?.role !== 'admin') {
return new Error('You can only delete your own posts');
}
return true;
}),
},
});
import { initTRPC, TRPCError } from '@trpc/server';
import { shield, rule, and, or, not } from 'trpc-shield';
import jwt from 'jsonwebtoken';
type User = {
id: string;
email: string;
role: 'user' | 'admin' | 'moderator';
emailVerified: boolean;
};
type Context = {
user?: User;
token?: string;
};
// Authentication rule with context extension
const authenticate = rule<Context>()(async (ctx) => {
if (ctx.user) return true;
if (!ctx.token) {
return new Error('Authentication required');
}
try {
const payload = jwt.verify(ctx.token, process.env.JWT_SECRET!) as any;
const user = await getUserById(payload.userId);
if (!user) {
return new Error('User not found');
}
// Extend context with user
return { ctx: { user } };
} catch {
return new Error('Invalid token');
}
});
// Authorization rules
const isAdmin = rule<Context>()(async (ctx) => ctx.user?.role === 'admin');
const isModerator = rule<Context>()(async (ctx) => ctx.user?.role === 'moderator');
const isEmailVerified = rule<Context>()(async (ctx) => ctx.user?.emailVerified === true);
// Permission definitions
const permissions = shield<Context>({
query: {
// Public endpoints
publicPosts: true,
healthCheck: true,
// Authenticated endpoints
profile: authenticate,
notifications: and(authenticate, isEmailVerified),
// Admin endpoints
userList: and(authenticate, isAdmin),
analytics: and(authenticate, or(isAdmin, isModerator)),
},
mutation: {
// Public mutations
register: not(authenticate), // Only unauthenticated users
login: not(authenticate),
// Authenticated mutations
updateProfile: and(authenticate, isEmailVerified),
createPost: authenticate,
// Admin mutations
deleteUser: and(authenticate, isAdmin),
banUser: and(authenticate, or(isAdmin, isModerator)),
},
});
// tRPC setup
const t = initTRPC.context<Context>().create();
export const middleware = t.middleware(permissions);
export const protectedProcedure = t.procedure.use(middleware);
// Usage in router
export const appRouter = t.router({
profile: protectedProcedure
.query(({ ctx }) => {
// ctx.user is guaranteed to exist and be typed correctly
return {
id: ctx.user.id,
email: ctx.user.email,
role: ctx.user.role,
};
}),
updateProfile: protectedProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
// User is authenticated and email verified
return await updateUser(ctx.user.id, { name: input.name });
}),
});
const isResourceOwner = (resourceType: string) =>
rule<Context>(`isOwnerOf${resourceType}`)(async (ctx, type, path, input) => {
const resource = await getResource(resourceType, input.id);
return resource.ownerId === ctx.user?.id;
});
const permissions = shield<Context>({
mutation: {
updatePost: and(authenticate, isResourceOwner('post')),
deleteComment: and(authenticate, or(
isResourceOwner('comment'),
isResourceOwner('post'), // Post owner can delete comments
isAdmin
)),
},
});
tRPC Shield is extensively tested with comprehensive coverage. Test your rules in isolation:
import { describe, it, expect } from 'vitest';
describe('Authentication Rules', () => {
it('should allow authenticated users', async () => {
const ctx = { user: { id: '1', role: 'user' } };
const result = await isAuthenticated.resolve(ctx, 'query', 'profile', {}, {}, {});
expect(result).toBe(true);
});
it('should extend context with user data', async () => {
const ctx = { token: 'valid-jwt-token' };
const result = await authenticate.resolve(ctx, 'query', 'profile', {}, {}, {});
expect(result).toEqual({ ctx: { user: expect.any(Object) } });
});
});
-
Use
deny
as fallback for sensitive applications:shield(permissions, { fallbackRule: deny })
-
Validate input in rules:
const isOwner = rule<Context>()(async (ctx, type, path, input) => { if (!input?.id) return new Error('Resource ID required'); // ... rest of logic });
-
Don't expose sensitive errors in production:
shield(permissions, { allowExternalErrors: process.env.NODE_ENV === 'development' })
-
Use specific error messages for better UX:
const hasPermission = rule<Context>()(async (ctx) => { if (!ctx.user) return new Error('Please log in to continue'); if (!ctx.user.emailVerified) return new Error('Please verify your email'); return true; });
Creates a tRPC middleware from your permission rules.
Parameters:
permissions
- Object defining rules for queries and mutationsoptions
- Configuration object
Options:
Option | Type | Default | Description |
---|---|---|---|
allowExternalErrors |
boolean |
false |
Allow custom errors to bubble up |
debug |
boolean |
false |
Enable debug logging |
fallbackRule |
Rule |
allow |
Default rule for undefined paths |
fallbackError |
string | Error |
"Not Authorised!" |
Default error message |
Creates a permission rule.
Parameters:
name
- Optional rule name for debuggingfn
- Rule function(ctx, type, path, input, rawInput, options) => boolean | Error | {ctx: object}
and(...rules)
- All rules must passor(...rules)
- At least one rule must passnot(rule, error?)
- Rule must failchain(...rules)
- Execute rules sequentially, all must passrace(...rules)
- Execute rules sequentially until one passes
allow
- Always allows accessdeny
- Always denies access
We welcome contributions! Please see our Contributing Guide for details.
git clone https://github.com/omar-dulaimi/trpc-shield.git
cd trpc-shield
npm install
npm run build
npm test
This project is licensed under the MIT License - see the LICENSE file for details.
- Inspired by GraphQL Shield
- Built for the amazing tRPC ecosystem
- Shield icon by Freepik from Flaticon
Made with β€οΈ by the tRPC Shield team
β Star us on GitHub β’ π Report Issues β’ π¬ Discussions