Authorization
Auth tells you who is making the request. Authorization tells you whether they are allowed to do what they are asking.
In Slingshot, these are separate concerns. Auth gives you an actor. The permissions system decides what that actor can access. This page covers the mental model and the surfaces you’ll touch most often. For plugin setup, grant lifecycle, and operational patterns, see Permissions: Setup and Patterns.
The simplest version: role checks
Section titled “The simplest version: role checks”If all you need is “admins can delete, everyone else can read,” use route-level auth:
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), body: field.string(), authorId: field.string(), }, routes: { defaults: { auth: 'userAuth' }, delete: { auth: 'userAuth', middleware: ['requireAdmin'] }, },});This works when permissions are purely role-based. Every authenticated user can read, only admins can delete.
Resource-level permissions
Section titled “Resource-level permissions”When you need “users can only edit their own posts” or “org members can access their org’s data,” add the permissions package:
bun add @lastshotlabs/slingshot-permissions// @skip-typecheckimport { defineApp } from '@lastshotlabs/slingshot';import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';import { getAuthRuntimePeerOrNull } from '@lastshotlabs/slingshot-core';import { createAuthGroupResolver, createPermissionsPackage,} from '@lastshotlabs/slingshot-permissions';
export default defineApp({ port: 3000, security: { signing: { secret: process.env.JWT_SECRET! } }, plugins: [createAuthPlugin({ auth: { roles: ['user'] } })], packages: [ createPermissionsPackage({ groupResolver: pluginState => createAuthGroupResolver(() => getAuthRuntimePeerOrNull(pluginState)), }), ],});Permissions in entity route policy
Section titled “Permissions in entity route policy”The most common use case — declare what is required per route, the framework enforces it:
// @skip-typecheckconst Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), body: field.string(), authorId: field.string(), }, routes: { defaults: { auth: 'userAuth' }, list: { auth: 'none' }, get: { auth: 'none' }, create: { permission: { requires: 'post:create', scope: { resourceType: 'post' } }, }, update: { permission: { requires: 'post:update', ownerField: 'authorId', scope: { resourceType: 'post' }, }, }, delete: { permission: { requires: 'post:delete', ownerField: 'authorId', or: 'post:moderate', scope: { resourceType: 'post' }, }, }, },});ownerField: 'authorId' checks that record.authorId === actor.id. No extra code needed —
the framework fetches the record and checks ownership automatically.
Permissions in custom routes
Section titled “Permissions in custom routes”For package routes, the same permission option applies:
// @skip-typecheckroute.delete({ path: '/posts/:id/archive', auth: 'userAuth', permission: { requires: 'post:archive', scope: { resourceType: 'post' } }, handler: async ({ params, respond }) => { // Permission check already passed — safe to proceed await archivePost(params.id); return respond.noContent(); },});For checks that depend on runtime values, inject the evaluator via services and use
actor.id from the handler context directly:
// @skip-typecheckimport { getPermissionsState } from '@lastshotlabs/slingshot-core';
const { evaluator } = getPermissionsState(app);
domain({ name: 'posts', basePath: '/posts', services: { evaluator }, routes: [ route.delete({ path: '/:id', auth: 'userAuth', handler: async ({ actor, params, services, respond }) => { const post = await getPost(params.id);
const allowed = await services.evaluator.can( { subjectId: actor.id!, subjectType: 'user' }, 'post:delete', { tenantId: actor.tenantId ?? undefined, resourceType: 'post', resourceId: post.id }, );
if (!allowed) return respond.json({ error: 'Forbidden' }, 403); await deletePost(post.id); return respond.noContent(); }, }), ],});Permissions in WebSocket guards
Section titled “Permissions in WebSocket guards”The same evaluator works for realtime access control:
// @skip-typecheckonRoomSubscribe: async (ws, room) => { const allowed = await evaluator.can( { subjectId: ws.data.actor.id, subjectType: 'user' }, 'room:join', { tenantId: ws.data.requestTenantId ?? undefined, resourceType: 'room', resourceId: room }, ); return allowed;};One permission model across HTTP routes, entities, and realtime.
Next in your journey
Section titled “Next in your journey”Your app is now read-write, identified, and authorized. Most apps want some pieces of state to flow live to clients without polling:
- Realtime — Server-Sent Events and WebSockets, with auth and permissions inherited from this layer.
Go deeper into permissions
Section titled “Go deeper into permissions”- Permissions Guide — full setup, grants, and evaluation patterns
- Route Policy — per-route auth and permission configuration
- Auth — identity and sessions (separate from permissions)
- SaaS Foundations — auth + orgs + permissions end-to-end