Skip to content

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.

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.

When you need “users can only edit their own posts” or “org members can access their org’s data,” add the permissions package:

Terminal window
bun add @lastshotlabs/slingshot-permissions
app.config.ts
// @skip-typecheck
import { 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)),
}),
],
});

The most common use case — declare what is required per route, the framework enforces it:

// @skip-typecheck
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' },
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.

For package routes, the same permission option applies:

// @skip-typecheck
route.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-typecheck
import { 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();
},
}),
],
});

The same evaluator works for realtime access control:

// @skip-typecheck
onRoomSubscribe: 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.

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.
  • 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