| name | rebase-security |
| description | Comprehensive guide to the Rebase backend security architecture. Use this skill when the user asks about securing their application, backend-level access control, request interception, global callbacks for security, fail-closed design, or how security works without database-level RLS. Also use when the user needs to implement PII masking, tenant isolation, role-based access control at the API layer, or cross-cutting security concerns. |
Rebase Security Architecture
Rebase implements a multi-layered, defense-in-depth security architecture. Security is enforced at the application level — not just at the database level. This means your data is protected regardless of whether the underlying database supports native Row-Level Security (RLS) or not.
IMPORTANT FOR AGENTS: Always read the rebase-basics and rebase-auth skills for auth configuration details. This skill focuses on the security architecture and backend-level enforcement mechanisms.
Table of Contents
Security Architecture Overview
Every request — REST, GraphQL, and WebSocket — passes through 5 security layers before data is returned to the client. These layers are enforced at the application level and work independently of any database-native security mechanism.
┌──────────────────────────────────────────────────────┐
│ CLIENT REQUEST │
│ (REST / GraphQL / WebSocket) │
└──────────────┬───────────────────────────────────────┘
│
┌──────────────▼───────────────────────────────────────┐
│ Layer 1: Auth Middleware │
│ JWT / Service Key / API Key / Custom AuthAdapter │
│ → Identifies user, scopes the DataDriver │
└──────────────┬───────────────────────────────────────┘
│
┌──────────────▼───────────────────────────────────────┐
│ Layer 2: API Key Permission Guard │
│ Per-collection, per-operation permission check │
│ (only for API key requests) │
└──────────────┬───────────────────────────────────────┘
│
┌──────────────▼───────────────────────────────────────┐
│ Layer 3: Global callbacks (every data path) │
│ Cross-cutting, for ALL collections │
│ afterRead / beforeSave / beforeDelete │
└──────────────┬───────────────────────────────────────┘
│
┌──────────────▼───────────────────────────────────────┐
│ Layer 4: Scoped DataDriver │
│ driver.withAuth(user) — applies RLS policies │
│ (PostgreSQL: SET LOCAL session vars + native RLS) │
└──────────────┬───────────────────────────────────────┘
│
┌──────────────▼───────────────────────────────────────┐
│ Layer 5: Collection Callbacks (Per-Collection) │
│ beforeSave / afterRead / beforeDelete │
│ Per-collection hooks inside the DataDriver │
└──────────────────────────────────────────────────────┘
KEY INSIGHT: Layers 1–3 and Layer 5 are application-level — they don't depend on the database. They are what secures a backend whose database has no native RLS.
But on Postgres, RLS is the authorization model, and Layer 4 is where it happens. A collection's securityRules are a source for code generation — db push turns them into pg_policies — and nothing on the data path reads them at runtime. The application layers redact and validate; only the database decides who may see a row, and only it still applies when a cron, psql, or the SQL editor reaches the table. rebase doctor --policies diffs the deployed policies against what your collections generate, because a stale policy outlives any config fix.
Request Pipeline
All three protocols share the same security middleware stack:
REST
HTTP Request → Auth Middleware → API Key Guard → Scoped Driver → global callbacks → collection callbacks → property callbacks → Response
GraphQL
GraphQL Request → Auth Middleware → API Key Guard → Scoped Driver → Collection Callbacks → Response
GraphQL shares the same Hono middleware chain as REST. Resolvers extract the scoped driver from context and throw if unavailable.
WebSocket
WS Connect → AUTHENTICATE message → Token Verification → Per-Operation Scoped Driver → Response
WebSocket auth follows a message-based flow:
- Client sends an
AUTHENTICATE message with a JWT token.
- Token is verified via
extractUserFromToken(token).
- Session is marked as authenticated.
- Each subsequent data operation calls
getScopedDelegate() to create a user-scoped driver.
- Admin-only operations (e.g.,
EXECUTE_SQL) require isAdminSession().
- Rate limiting: 2000 messages per 60 seconds.
Layer 1: Auth Middleware
The auth middleware is the first line of defense. It runs on every request and does two things:
- Identifies the user — Extracts credentials from the
Authorization header (JWT, service key, API key, or custom adapter).
- Scopes the DataDriver — Calls
scopeDataDriver(driver, user) which invokes driver.withAuth(user) to return a security-scoped clone.
The scoped driver is placed into c.set("driver", scopedDriver). The raw, unscoped driver is never placed in the request context.
How Scoping Works
type RLSScopedDriver = DataDriver & {
withAuth(user: { uid: string; roles?: string[] }): Promise<DataDriver>;
};
function isRLSScopedDriver(driver: DataDriver): driver is RLSScopedDriver {
return "withAuth" in driver && typeof (driver as Record<string, unknown>).withAuth === "function";
}
export async function scopeDataDriver(
driver: DataDriver,
user: { uid: string; roles?: string[] }
): Promise<DataDriver> {
if (isRLSScopedDriver(driver)) {
return await driver.withAuth(user);
}
return driver;
}
Identity Types
| Auth Method | uid | roles | RLS Behavior |
|---|
| JWT (authenticated user) | User's ID | User's app roles | Full RLS enforcement |
| Service Key | "service" | ["admin"] | Bypasses RLS (admin access) |
| API Key (default) | "api-key:{id}" | ["service"] | Bypasses RLS, scoped by permissions |
| API Key (admin) | "api-key:{id}" | ["admin", "service"] | Bypasses RLS, full admin access |
Anonymous (requireAuth: false) | "anon" | ["anon"] | RLS with anonymous identity |
No token + requireAuth: true | — | — | Rejected (401) |
IMPORTANT FOR AGENTS: These are reserved system identity values that the middleware injects automatically. When writing callbacks, developers should use these identities to gate behavior:
uid: "service" + roles: ["admin"] — Server-side rebase.data calls (cron jobs, custom functions using rebase.data, webhooks). These go through the full middleware pipeline authenticated with the service key.
uid: "anon" + roles: ["anon"] — Unauthenticated requests when requireAuth: false. Note: for anonymous REST requests, context.user in Collection Callbacks may be undefined; only the DataDriver is scoped with the anon identity. For WebSocket connections, a full User object with uid: "anon" is provided.
uid: "api-key:{id}" + roles: ["service"] (or ["admin", "service"]) — API key requests.
- Real user IDs and roles for JWT-authenticated requests.
Key insight: rebase.data (the server-side singleton) is NOT a raw admin driver — it round-trips through the REST API using the service key, so all callbacks fire with uid: "service", roles: ["admin"]. This means callbacks can distinguish server-internal reads from end-user reads by checking context.user?.roles?.includes("admin").
Layer 2: API Key Permission Guard
When a request is authenticated via an API key (prefixed rk_), the permission guard enforces per-collection, per-operation access control:
interface ApiKeyPermission {
collection: string;
operations: ("read" | "write" | "delete")[];
}
GET → requires "read" permission
POST / PUT / PATCH → requires "write" permission
DELETE → requires "delete" permission
This layer runs in both REST and GraphQL. If the API key lacks the required permission, the request is rejected with 403 Forbidden.
Layer 3: Global callbacks (every data path)
Global callbacks are the primary mechanism for backend-level security when you cannot or do not want to use database-level RLS. They apply to every collection — a single cross-cutting point — and, unlike an API-boundary interceptor, they fire on every data path: REST, WebSocket/realtime, and server-side rebase.data. There is no read path that bypasses afterRead, which is what makes it safe to rely on for redaction.
Configuration
They are configured via the callbacks property of initializeRebaseBackend(), and take the same CollectionCallbacks type as a per-collection callbacks block:
import { initializeRebaseBackend } from "@rebasepro/server";
import type { CollectionCallbacks } from "@rebasepro/types";
const callbacks: CollectionCallbacks = {
afterRead({ row, collection, path, context }) {
return row;
},
beforeSave({ values, id, collection, context }) {
return values;
},
beforeDelete({ id, collection, context }) { },
afterSave({ id, values, collection, context }) { },
afterDelete({ id, collection, context }) { },
};
await initializeRebaseBackend({
server,
app,
database: createPostgresAdapter({ connection: db, schema }),
auth: { jwtSecret: "...", },
callbacks,
});
FOR AGENTS: callbacks take a single props object, not positional
arguments, and the read payload is a flat row — not an Entity. Entity is
an admin-UI view model and never reaches this layer.
CollectionCallbacks Interface
import type { User } from "@rebasepro/types";
type CollectionCallbacks<M extends Record<string, unknown>, USER extends User> = {
afterRead?(props: AfterReadProps<M, USER>):
Promise<Record<string, unknown>> | Record<string, unknown>;
beforeSave?(props: BeforeSaveProps<M, USER>):
Promise<Partial<EntityValues<M>>> | Partial<EntityValues<M>>;
afterSave?(props: AfterSaveProps<M, USER>): Promise<void> | void;
afterSaveError?(props: AfterSaveErrorProps<M, USER>): Promise<void> | void;
beforeDelete?(props: BeforeDeleteProps<M, USER>): Promise<boolean | void> | boolean | void;
afterDelete?(props: AfterDeleteProps<M, USER>): Promise<void> | void;
};
interface AfterReadProps<M extends Record<string, unknown>, USER extends User> {
collection: CollectionConfig<M>;
path: string;
row: Record<string, unknown>;
context: RebaseCallContext<USER>;
}
Blocking vs Fire-and-Forget
| Callback | Can Block? | How to Block |
|---|
beforeSave | Yes | Throw an error to abort the save (returns an HTTP error) |
beforeDelete | Yes | Throw an error to prevent deletion |
afterRead | Redacts, does not block | Return a modified row. It returns a row, not null — filter rows out with RLS, not here |
afterSave | No | Post-write side effect |
afterSaveError | No | Fires when a save fails |
afterDelete | No | Post-delete side effect |
Execution Order
global callbacks → collection callbacks → property callbacks.
The global block you pass to initializeRebaseBackend runs first, then any
callbacks declared on the collection itself, then per-property ones. All three
are the same mechanism at different scopes, which is why they share one type.
Layer 4: Scoped DataDriver
The scoped DataDriver is the layer where database-level RLS is enforced. For PostgreSQL, withAuth() wraps every operation in a transaction with session variables:
SELECT
set_config('app.user_id', :userId, true),
set_config('app.user_roles', :rolesString, true),
set_config('app.jwt', :jwtClaims, true)
PostgreSQL RLS policies use auth.uid(), auth.roles(), and auth.jwt() to read these session variables and enforce row-level access control.
IMPORTANT: This layer is database-specific. If your project does not use PostgreSQL RLS, security is still enforced by Layers 1–3 and Layer 5. See Securing Without Database RLS.
Layer 5: Collection Callbacks
Collection callbacks are per-collection lifecycle hooks that run inside the DataDriver, close to the database. They provide collection-specific security enforcement:
type Order = { total: number; status: string };
const ordersCollection: PostgresCollectionConfig<Order> = {
name: "Orders",
slug: "orders",
table: "orders",
callbacks: {
beforeSave: async ({ values, context }) => {
const user = context.user;
if (values.total > 10000 && !user?.roles?.includes("admin")) {
throw new Error("High-value orders require admin approval");
}
return values;
},
beforeDelete: async ({ row, context }) => {
if (row.status === "fulfilled") {
throw new Error("Cannot delete fulfilled orders");
}
},
},
properties: { }
};
For full documentation on collection callbacks, see the rebase-collections skill.
Fail-Closed Design
Rebase follows a fail-closed security model throughout the stack:
-
Scoped driver or nothing — The REST API's getScopedDriver() throws if no scoped driver is available. It never falls back to the unscoped driver:
private getScopedDriver(c): DataDriver {
const driver = c.get("driver") as DataDriver | undefined;
if (!driver) throw ApiError.internal("Scoped driver not available");
return driver;
}
-
RLS scoping failures are fatal — If driver.withAuth() throws, the error propagates and the request is rejected with 500. The system does not silently skip RLS.
-
Unauthenticated requests are rejected — When requireAuth: true (the default), requests without a valid token receive 401. The unscoped driver never reaches the handler.
-
API keys with missing permissions are rejected — If an API key lacks the required permission for a collection/operation, the request is rejected with 403.
Securing Without Database RLS
If you cannot modify database-level RLS policies — or your database doesn't support them — use global callbacks to enforce security entirely at the application level.
Strategy: global callbacks as your security layer
import { ApiError } from "@rebasepro/server";
import type { CollectionCallbacks } from "@rebasepro/types";
const callbacks: CollectionCallbacks = {
afterRead({ row, collection, context }) {
const user = context.user;
if (user?.roles?.includes("admin")) return row;
if (collection.slug === "customers") {
return { ...row, email: "***", phone: "***" };
}
return row;
},
beforeSave({ values, id, collection, context }) {
const user = context.user;
if (!user) throw ApiError.unauthorized("Authentication required");
if (id === undefined) {
values.user_id = user.uid;
}
if (!user.roles?.includes("admin")) {
delete values.role;
delete values.is_admin;
}
return values;
},
beforeDelete({ id, collection, context }) {
const user = context.user;
if (!user) throw ApiError.unauthorized("Authentication required");
if (!user.roles?.includes("admin")) {
throw ApiError.forbidden("Only admins can delete records");
}
}
};
The honest caveat: on Postgres this is defence in depth, not the
authorization model. Authorization is RLS — securityRules in a collection are
a source for code generation (db push → policies.sql → pg_policies), and
nothing on the data path reads them at runtime. Callbacks run in the
application, so anything reaching the database by another route (psql, a cron,
the SQL editor) never sees them. Use them for redaction and validation; use RLS
to decide who may see a row.
Strategy: Collection Callbacks for Ownership Checks
Collection Callbacks receive the full entity data, making them ideal for ownership verification on deletes and updates:
const ordersCollection: PostgresCollectionConfig = {
name: "Orders",
slug: "orders",
table: "orders",
callbacks: {
beforeSave: async ({ values, id, context, previousValues }) => {
const user = context.user;
if (!user) throw new Error("Unauthorized");
if (id && previousValues) {
if (previousValues.user_id !== user.uid && !user.roles?.includes("admin")) {
throw new Error("You can only edit your own orders");
}
}
if (!id) {
values.user_id = user.uid;
}
return values;
},
beforeDelete: async ({ row, context }) => {
const user = context.user;
if (!user) throw new Error("Unauthorized");
if (row.user_id !== user.uid && !user.roles?.includes("admin")) {
throw new Error("You can only delete your own orders");
}
},
},
properties: { }
};
Combining Both Layers
Global and per-collection callbacks are the same mechanism at two scopes. Pick by blast radius:
| Concern | Best Layer | Why |
|---|
| Cross-cutting field redaction | global afterRead | Applies to ALL collections in one place |
| Cross-cutting write validation | global beforeSave | Single enforcement point for all writes |
| Withholding rows entirely | RLS policy | afterRead returns a row and cannot drop one |
| Ownership checks on writes/deletes | collection callbacks | Collection-scoped, has the row's values |
| Business rule validation | collection callbacks | Collection-specific, typed values |
| Audit logging | global afterSave / afterDelete | Cross-cutting, post-write |
Common Security Patterns
PII Masking
This is what afterRead is for: it fires on every read path, and it returns a
row, so masking fields is exactly its shape.
const callbacks: CollectionCallbacks = {
afterRead({ row, context }) {
if (context.user?.roles?.includes("admin")) return row;
const masked = { ...row };
if (masked.email) masked.email = "***@***.***";
if (masked.phone) masked.phone = "***-***-****";
if (masked.ssn) masked.ssn = "***-**-****";
return masked;
}
};
Tenant Isolation (Multi-Tenancy)
Withholding rows is RLS's job, not a callback's. afterRead returns a row —
it cannot drop one — so tenant filtering belongs in a policy, where Postgres
applies it inside the query. Use a callback only to stamp the tenant on write.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY documents_tenant ON documents FOR ALL TO public
USING (tenant_id = (auth.jwt() -> 'tenant_id')::text);
const callbacks: CollectionCallbacks = {
beforeSave({ values, id, context }) {
const tenantId = context.user?.roles
?.find(r => r.startsWith("tenant:"))
?.replace("tenant:", "");
if (!tenantId) throw ApiError.forbidden("No tenant assigned");
if (id === undefined) values.tenant_id = tenantId;
else if (values.tenant_id && values.tenant_id !== tenantId) {
throw ApiError.forbidden("Cross-tenant write");
}
return values;
}
};
Run rebase db push after adding the policy — the config is only its source.
Editing a rule renames its policy
A rule without an explicit name compiles to <table>_<op>_<hash>, where the hash covers the rule's semantics. So editing a rule (as opposed to adding one) produces a policy under a new name, and the policy under the old name is a leftover.
This used to matter a great deal: Postgres ORs PERMISSIVE policies together, so a superseded USING (auth.uid() IS NOT NULL) kept granting everything no matter how tight its replacement was. Tightening a rule had no effect, and push reported success.
db push now reconciles this automatically — it drops generated policies that no longer correspond to any rule, and reports (without dropping) any custom-named policy it finds that your collections don't describe, since those are indistinguishable from SQL someone wrote deliberately.
To audit an existing database — including one that was pushed before this landed — run:
rebase doctor --policies
It exits non-zero on drift, so it works as a CI gate.
Membership-Scoped Access (RLS, no N+1)
When membership lives in a join collection (e.g. team_members), prefer the
first-class policy.existsIn predicate over a per-row afterRead lookup. It is
enforced by Postgres RLS in a single correlated EXISTS subquery — no N+1, and
it cannot be bypassed by a client that skips the SDK.
import { policy } from "@rebasepro/types";
securityRules: [
{
operation: "select",
condition: policy.existsIn({
collection: "team_members",
where: policy.and(
policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")),
policy.compare(policy.field("user_id"), "eq", policy.authUid()),
),
}),
},
]
Inside where: policy.field(...) = a column of the joined collection
(team_members); policy.outerField(...) = a column of the row being checked
(documents); policy.authUid() = the caller. Reach for the afterRead
approach above only when access depends on data RLS can't see (e.g. an external
service). Run rebase db push after editing the collection to apply the policy.
Role-Based Collection Access
const ROLE_ACCESS: Record<string, string[]> = {
"financial_reports": ["admin", "finance"],
"hr_records": ["admin", "hr"],
"system_config": ["admin"]
};
const callbacks: CollectionCallbacks = {
beforeSave({ values, collection, context }) {
const allowed = ROLE_ACCESS[collection.slug];
const roles = context.user?.roles ?? [];
if (allowed && !allowed.some(r => roles.includes(r))) {
throw ApiError.forbidden(`Insufficient permissions for ${collection.slug}`);
}
return values;
}
};
For the read side of this, write the same role check as an RLS policy on the
collection. afterRead cannot withhold a row, so gating reads there is not an
option — see the note under Tenant Isolation.
Immutable Records (Soft Delete Only)
const callbacks: CollectionCallbacks = {
beforeDelete({ collection }) {
const immutable = ["audit_logs", "transactions", "invoices"];
if (immutable.includes(collection.slug)) {
throw ApiError.forbidden(
`Records in "${collection.slug}" cannot be deleted. Use soft-delete instead.`
);
}
},
beforeSave({ values, id, collection }) {
const appendOnly = ["audit_logs"];
if (appendOnly.includes(collection.slug) && id !== undefined) {
throw ApiError.forbidden(
`Records in "${collection.slug}" are append-only and cannot be updated.`
);
}
return values;
}
};
Security Checklist
Use this checklist when setting up security for a Rebase project:
References
- RLS Scope:
packages/server/src/auth/rls-scope.ts — scopeDataDriver() implementation
- Auth Middleware:
packages/server/src/auth/middleware.ts — JWT/service key/API key middleware
- Adapter Middleware:
packages/server/src/auth/adapter-middleware.ts — Custom auth adapter middleware
- API Key Guard:
packages/server/src/auth/api-keys/api-key-permission-guard.ts
- REST API Generator:
packages/server/src/api/rest/api-generator.ts — request/response path
- Callback Types:
packages/types/src/types/entity_callbacks.ts — CollectionCallbacks, AfterReadProps, BeforeSaveProps
- Backend Init:
packages/server/src/init.ts — hooks config property
- Reserved Identity Values: See Identity Types table above —
"service", "anon", "api-key:{id}" are system-assigned identities in context.user
- Collection Callbacks: See
rebase-collections skill → Collection Callbacks section
- Auth Configuration: See
rebase-auth skill → Server-Side Configuration section