Skip to content

Auth

You want users to create accounts, log in, and access protected resources. Slingshot’s auth package gives you registration, login, JWT sessions, and route protection out of the box.

Terminal window
bun add @lastshotlabs/slingshot-auth
app.config.ts
import { defineApp } from '@lastshotlabs/slingshot';
import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';
export default defineApp({
port: 3000,
security: {
signing: { secret: process.env.JWT_SECRET! },
},
plugins: [
createAuthPlugin({
auth: { roles: ['user', 'admin'], defaultRole: 'user' },
}),
],
packages: [
// your packages here
],
});

That registers these routes automatically:

RouteWhat it does
POST /auth/registerCreate a new account
POST /auth/loginLog in, get a JWT token
POST /auth/logoutEnd the session
GET /auth/meGet the current user
Terminal window
# Register
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{ "email": "alice@example.com", "password": "supersecret" }'
# Login — returns a JWT token
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "alice@example.com", "password": "supersecret" }'
# Use the token on protected routes
curl http://localhost:3000/auth/me \
-H "Authorization: Bearer <token>"

Once auth is mounted, use auth: 'userAuth' on any route to require a valid session:

route.get({
path: '/dashboard',
auth: 'userAuth',
handler: ({ actor, respond }) => {
return respond.json({ userId: actor.id, roles: actor.roles });
},
});

For entities, set routes.defaults to protect all generated routes at once:

const Task = defineEntity('Task', {
namespace: 'app',
fields: {
id: field.string({ primary: true, default: 'uuid' }),
title: field.string(),
done: field.boolean({ default: false }),
},
routes: {
defaults: { auth: 'userAuth' },
},
});

The auth middleware sets the actor on every request. Read it with getActor(c):

import { getActor, getActorId } from '@lastshotlabs/slingshot-core';
declare const c: Parameters<typeof getActor>[0];
// Full actor object — id, roles, tenantId, sessionId, claims
const actor = getActor(c);
// Just the user ID (null for anonymous requests)
const userId = getActorId(c);

These are opt-in. Enable them in the auth config when you need them:

createAuthPlugin({
auth: {
roles: ['user', 'admin'],
defaultRole: 'user',
oauth: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
mfa: { enabled: true },
passkeys: { enabled: true, rpName: 'My App', rpId: 'myapp.com' },
},
});

Require a specific role with requireRole:

// @skip-typecheck
import { requireRole } from '@lastshotlabs/slingshot-auth';
router.use('/admin/*', requireRole('admin'));

For entity routes, use route policy:

const Post = defineEntity('Post', {
namespace: 'blog',
fields: {
id: field.string({ primary: true, default: 'uuid' }),
title: field.string(),
body: field.string(),
},
routes: {
defaults: { auth: 'userAuth' },
delete: { middleware: ['requireAdmin'] },
},
});

Auth fires lifecycle hooks at well-defined moments:

HookWhen it firesReturn value used for
preLoginBefore credential verificationSide effects only
postLoginAfter session is persisted, before the JWT is signed{ customClaims } merged into the JWT
preRegisterBefore account creationSide effects only
postRegisterAfter account creationSide effects only
prePasswordChangeBefore password is rotatedSide effects only
postPasswordChangeAfter password is rotatedSide effects only
preDeleteAccountBefore account row is removedSide effects only
postDeleteAccountAfter account row is removedSide effects only

Each hook receives the relevant identifiers plus a HookContext that carries request metadata (ip, userAgent, requestId) and a services?: HookServices accessor for reading entities, resolving capabilities, and emitting events. See HookServices for the full contract.

// @skip-typecheck
import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';
interface ProfileAdapter {
findByUserId(id: string): Promise<{ plan: 'free' | 'pro' } | null>;
}
createAuthPlugin({
auth: {
primaryField: 'email',
roles: ['user'],
defaultRole: 'user',
hooks: {
// postLogin can return { customClaims } — those keys are merged into the
// signed JWT (after reserved JOSE claims are stripped).
postLogin: async ({ userId, services }) => {
if (!services) return undefined;
const profiles = services.entities.get<ProfileAdapter>({ entity: 'Profile' });
const profile = await profiles.findByUserId(userId);
if (!profile) return undefined;
return { customClaims: { plan: profile.plan } };
},
// The other hooks are for side effects — emit events, write audit rows, etc.
postRegister: async ({ userId, services }) => {
services?.bus.emit('app:user.registered', { userId });
},
},
},
});

The postLogin hook is the only one whose return value is read; reserved JOSE claims (exp, iat, nbf, iss, aud, jti, sub, sid) are always stripped from customClaims before signing, so a hook cannot override the identity claims auth itself sets.

Error semantics differ by phase:

  • pre* hooks are awaited inline. Throwing from one aborts the operation — the login, registration, password change, or delete fails and the error surfaces to the caller. Use this when you need to veto an operation.
  • post* hooks are fire-and-forget. Errors are caught and logged via console.error; they never abort the operation that triggered them. Use these for side effects (audit rows, downstream events, custom claims) where failure should not roll back the primary operation.

You don’t need slingshot-auth. If you already have auth upstream (API gateway, reverse proxy, your own JWT verification) or want to use a different auth library, write a plugin that:

  1. Sets the actor on the Hono context
  2. Registers a RouteAuthRegistry so entity routes and framework features can enforce auth: 'userAuth'
// @skip-typecheck
import type { SlingshotPlugin } from '@lastshotlabs/slingshot-core';
import type { Actor } from '@lastshotlabs/slingshot-core';
export function createMyAuthPlugin(): SlingshotPlugin {
return {
name: 'my-auth',
setupMiddleware({ app }) {
// Run on every request — resolve identity from your upstream
app.use('*', async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (token) {
const payload = await verifyMyToken(token); // your verification
const actor: Actor = {
id: payload.sub,
kind: 'user',
tenantId: payload.tenantId ?? null,
sessionId: null,
roles: payload.roles ?? [],
claims: payload,
};
c.set('actor', actor);
}
await next();
});
},
setupPost({ registrar }) {
// Register the auth registry so entity routes can use auth: 'userAuth'
registrar.setRouteAuth({
userAuth: async (c, next) => {
const actor = c.get('actor');
if (!actor?.id) return c.json({ error: 'Unauthorized' }, 401);
await next();
},
requireRole(...roles) {
return async (c, next) => {
const actor = c.get('actor');
if (!actor?.roles?.some((r: string) => roles.includes(r))) {
return c.json({ error: 'Forbidden' }, 403);
}
await next();
};
},
});
},
};
}

Then use it like slingshot-auth:

app.config.ts
// @skip-typecheck
import { defineApp } from '@lastshotlabs/slingshot';
import { appPackage } from './src/app/package';
import { createMyAuthPlugin } from './src/auth/myAuthPlugin';
export default defineApp({
port: 3000,
plugins: [createMyAuthPlugin()],
packages: [appPackage],
});

Entity routes with auth: 'userAuth' will call your userAuth middleware. getActor(c), getActorId(c), and all actor-based features (permissions, data scoping, audit) work automatically because they read from c.get('actor').

Auth tells you who the actor is. The next step is what they’re allowed to do:

  • Permissions — resource-level access control built on top of the actor model.