Palbase
Sign inGet started

Backend SDK

Authentication

A Palbase route requires a signed-in user unless it says otherwise: the effective rule is route → controller → required, and only a literal false (or { required: false }) opts out, so a route that declares nothing is closed rather than open. Identity arrives as a Bearer token that the runtime verifies in-process against your Environment's own JWKS — ES256 over P-256, nothing else — and turns into the object @User() injects. The publishable key your client sends on the apikey header says which Environment it is talking to; it never says who is calling. See the two API keys for the two keys every Environment is minted with.

Quick example

// modules/todos/dto/todo.ts
// The schema is a value and a type with the same name.
import { z } from "@palbase/backend";

export const Todo = z.object({
  id: z.string(),
  title: z.string(),
  done: z.boolean(),
});
export type Todo = z.infer<typeof Todo>;

export const CreateTodo = z.object({ title: z.string().min(1) });
export type CreateTodo = z.infer<typeof CreateTodo>;
// modules/todos/todo.service.ts
import { Database, Injectable } from "@palbase/backend";

import { Todo } from "./dto/todo";

@Injectable()
export class TodoService {
  create(userId: string, title: string): Promise<Todo> {
    return Database.public.todos.insert({ user_id: userId, title });
  }

  featured(userId: string | null): Promise<Todo[]> {
    return userId === null
      ? Database.public.todos.findMany({ where: { done: true }, limit: 20 })
      : Database.public.todos.findMany({ where: { user_id: userId }, limit: 20 });
  }
}
// modules/todos/todos.controller.ts
import { Body, Controller, Get, OptionalUser, Post, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

import { CreateTodo, Todo } from "./dto/todo";
import { TodoService } from "./todo.service";

@Controller("/todos")                       // no auth option → every route requires a user
export class TodosController {
  // The container supplies it; `modules/todos/todos.module.ts` lists both.
  constructor(private readonly todos: TodoService) {}

  @Post("")
  create(@Body(CreateTodo) body: CreateTodo, @User() user: UserT): Promise<Todo> {
    return this.todos.create(user.id, body.title);   // never null on a required route
  }

  @Get("/featured", { auth: false })        // one public route
  featured(@OptionalUser() user: UserT | null): Promise<Todo[]> {
    return this.todos.featured(user?.id ?? null);    // may be null — handle it
  }
}

Note: The decorator records the class, but recording is not mounting. What makes a controller exist is the controllers list of a @Modulemodules/todos/todos.module.ts imports TodosController and names it there — and a class no module lists is refused at build, by name, so it never reaches the route table, the dispatcher or the OpenAPI document. Export the class by NAME, because that is how the module takes it: import { TodosController } from "./todos.controller". A export default class paired with that import does not compile, and the scaffold writes export class for exactly this reason. The module's list is the registration; the named export is what makes the class reachable from it.

The auth cascade

The effective rule for a route resolves most-specific-first, in four steps:

  1. Route-level@Get("", { auth }) wins if present.
  2. Controller-level@Controller("/x", { auth }) covers routes that declare nothing.
  3. Application-leveldefineDefaultAuth(...), one declaration covering every controller that did not speak for itself.
  4. The default — required.

Step 3 is the one to know about, because it can tighten a route without anything at the route saying so: a project whose auth.ts calls defineDefaultAuth({ verifiedEmail: true }) makes every undeclared handler demand a confirmed address, and a 403 from a @Get("") that declares nothing is explained nowhere else.

// modules/public/public.controller.ts
import { Controller, Get, User, z } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

export const Info = z.object({ version: z.string() });
export type Info = z.infer<typeof Info>;

export const Profile = z.object({ id: z.string() });
export type Profile = z.infer<typeof Profile>;

@Controller("/public", { auth: false })       // controller default: public
export class PublicController {
  @Get("/open")
  open(): Promise<Info> {                     // inherits → public
    return Promise.resolve({ version: "1" });
  }

  @Get("/me", { auth: true })                 // route override → requires a user
  me(@User() user: UserT): Promise<Profile> {
    return Promise.resolve({ id: user.id });
  }
}

The auth value

auth is a boolean or a partial AuthConfig:

import type { PalbaseRoleName } from "@palbase/backend";

interface AuthConfig {
  required: boolean;
  /** One of YOUR application roles, resolved from `auth.user_roles` per request.
   *  `PalbaseRoleName` is the union `palbase build` renders from the stack, so a
   *  misspelt role is a compile error rather than a silent 403. */
  role?: PalbaseRoleName;
  /** `resource.action`, resolved through `auth.has_permission(...)`. A plain
   *  string: permissions live on roles in the database and nothing renders them. */
  permission?: string;
  /** Requires a confirmed address; an unverified caller gets `403 email_not_verified`. */
  verifiedEmail?: boolean;
}
auth valueRequires a user?Notes
(omitted)yesthe secure default
trueyes
{}yesthe object form defaults to required
{ required: true }yes
{ role: "moderator" }yesnaming a role implies required
{ permission: "notes.delete_any" }yesnaming a permission implies required
{ verifiedEmail: true }yesrequires a user and a confirmed address
falsenopublic route
{ required: false }nopublic route

A request with no usable token to a required route is refused before your method runs:

{
  "error": "unauthorized",
  "error_description": "A valid access token is required",
  "status": 401,
  "request_id": "req_01h…"
}

Note: On a public route a present-but-unusable token (expired, stale after sign-out, signed by a key that has rotated away) is treated as anonymous — the request goes through with user === null, never a hard 401. Client SDKs attach a cached token to every call, so a signed-out user must not break your public endpoints. On a required route the same token gets the 401 above.

How the token is verified

Verification happens inside your backend process, on every request, with no round-trip to an auth service:

  • ES256 over P-256 only. That is the one thing your Environment's auth module mints, and an unrecognised alg is refused rather than accommodated — the classic JWT break is a verifier that is helpful about algorithms.
  • Keys come from your Environment's JWKS (AUTH_JWKS_URL, which points at your Environment's own auth module). The keyset is cached and refreshed at most once every five minutes, and at most once concurrently. There is no per-request identity cache and no profile lookup: what the handler sees is what the token said.
  • AUTH_ISSUER, when set, is enforced — a token whose iss differs is refused.
  • Every failure returns the same nothing. Absent header, malformed token, expired, wrong issuer, bad signature — all of them produce null, with no distinguishing error. A detailed reason would be an oracle for whoever is probing.

The engine reads only the Authorization header for identity. It does not read an inbound apikey header, because scoping is physical rather than logical: one Environment is one database, reached at one address, so a token is only ever judged against the Environment it was presented to. What the API key does — and why a signed-in Bearer always beats it — is on the two API keys.

Injecting the user

@User() — required routes

@User() injects the authenticated user. On a route whose effective auth is required, the 401 happens before your method runs, so the injected value is never null — no defensive check is needed.

Warning — User vs UserT: the value User is the decorator; the user type is exported as UserT. They are two different imports:

import { User } from "@palbase/backend";          // the @User() decorator
import type { UserT } from "@palbase/backend";    // the user type

@OptionalUser() — public routes

On a public route a token may or may not be present. Use @OptionalUser() and annotate UserT | null:

// modules/todos/feed.controller.ts
import { Controller, Get, OptionalUser } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

import { Todo } from "./dto/todo";
import { TodoService } from "./todo.service";

@Controller("/todos")
export class FeedController {
  constructor(private readonly todos: TodoService) {}

  @Get("/feed", { auth: false })
  feed(@OptionalUser() user: UserT | null): Promise<Todo[]> {
    return this.todos.featured(user?.id ?? null);
  }
}

The annotation is a convention, not a check — a parameter decorator cannot see the controller's auth default at the type level. The runtime always enforces the resolved rule regardless of which one you wrote.

The User shape

The object is built from the verified token's claims — with one deliberate exception, roles, which is read from the database on every request:

FieldTypeWhat it actually holds
idstringthe sub claim — a usr_… id, and the owner key to use in your tables
emailstring?the email claim; absent for phone-only users and for anonymous sessions
rolestringthe token's top-level role claim — "authenticated" for a signed-in user, "anon" for an anonymous session
emailVerifiedbooleanclaims.email_verified === true — a signed top-level claim, true once the address is confirmed
metadataRecord<string, unknown>claims.metadataalways {} today, see below

emailVerified works. It reads a signed top-level claim the token has carried since 2026-09-05, so { auth: { verifiedEmail: true } } fences exactly who it says it fences. What the claim cannot do is change inside a token that already exists: it states what was true when the token was minted, so a user who confirms mid-session keeps reporting false until the next one. That next one is a refresh away, not a sign-in — POST /auth/token/refresh re-reads the row when it mints, and the mobile SDKs spend that refresh for you (iOS from Palbe 0.57.0, Android from 2.1.0).

Warning: metadata does not carry what its name promises. A Palbase access token contains sub, iss, aud, exp, iat, jti, auth_time, session_id, email, email_verified, role, and — for a project that sets them through the token hook — custom_claims. There is no metadata claim, so user.metadata reads {} for every caller including one with custom claims, and an auth.users.metadata row filled through the admin API never reaches it. Do not branch on it. Keep your own per-user attributes in a table of your own and read them with Database.

roles is your application's authorization, and it is not a claim. It is a string[] resolved per request from auth.user_roles — the table palbase roles writes — so a grant or a revocation lands on the very next request, with the same access token, no refresh and no re-login. Empty array for a caller who holds none, never null. This is the field to branch on: role above is the DATABASE role and is always "authenticated", so user.role === "admin" is a check that never fires.

The User type also declares a device field. It is null unless the request arrived with a verified App Attest assertion, in which case the runtime fills it in; see App Attest.

Roles and permissions

Roles are yours. There is no built-in admin: you declare the roles your app has and what each one may do, from the command line —

palbase roles create member    --default --permissions notes.create
palbase roles create moderator           --permissions notes.create,notes.delete_any
palbase roles assign usr_01a0… moderator

— and palbase build brings the role names back as a type, so a misspelt role is a compile error rather than a silent 403. --default marks the one role every new sign-up receives, written in the same transaction as the user row.

import type { PalbaseRoleName } from "@palbase/backend";

// The union `palbase build` renders from the stack into `palbase/palbase-env.d.ts`.
// A project that has not built yet gets `never`, so an undeclared role does not compile.
const moderator: PalbaseRoleName = "moderator";

Permissions are not typed. auth: { permission } takes a plain string, because permissions live on roles in the database and no build step renders them. A typo there is a silent 403 — keep one constant per permission and reference it.

The endpoint gate

// modules/notes/notes.controller.ts
import { Controller, Delete, Get, Param, User, z } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

export const NoteSchema = z.object({ id: z.string(), body: z.string() });
export type NoteSchema = z.infer<typeof NoteSchema>;

@Controller("/notes")
export class NotesController {
  // Anyone signed in.
  @Get("")
  list(@User() user: UserT): Promise<NoteSchema[]> {
    return Promise.resolve([{ id: "nt_1", body: user.id }]);
  }

  // Only a caller holding the permission — the handler never runs otherwise.
  @Delete("/{id}", { auth: { permission: "notes.delete_any" } })
  remove(@Param("id") id: string): Promise<void> {
    return Promise.resolve();
  }

  // Or by role, when the check really is "is this caller a moderator".
  // `role` is typed: "moderatr" would not compile.
  @Get("/queue", { auth: { role: "moderator" } })
  queue(): Promise<NoteSchema[]> {
    return Promise.resolve([]);
  }
}
CallerResponse
Not signed in401 unauthorized — declaring a role or a permission implies authentication
Signed in, does not hold it403 forbidden, and the body names what was missing
Signed in, holds itthe handler runs
{
  "error": "forbidden",
  "error_description": "This endpoint requires the \"notes.delete_any\" permission",
  "status": 403,
  "request_id": "req_01h…"
}

Opening a route with auth: { required: false, permission: … } does not wave an anonymous caller through: no identity means no roles, and the gate answers 403.

Warning: Do not confuse auth: { role } with user.role. user.role is the DATABASE role the transaction runs as — "authenticated" for every signed-in caller — so user.role === "moderator" is a check that never fires. The application roles are user.roles, a string[], and auth: { role } reads the same table they come from.

Authority is in no claim. The gate reads auth.user_roles inside the transaction your handler is about to use — never a JWT claim — so palbase roles revoke closes the door on the very next request, with the same access token, no refresh and no re-login. Nothing is cached and there is no epoch to bump.

The gate decides whether the handler runs; it cannot decide which rows the handler sees. That is Postgres's job, and the same permissions are reachable from a policy through auth.has_permission('notes.delete_any') — see Row-Level Security.

Assigning a role from your own backend

Promotion usually happens inside the product: an account owner taps a button and your own handler writes the assignment. Auth is the ambient service for that, and it is the operator half of auth — not sign-up, sign-in or MFA, which belong to the person and run in their app.

// modules/staff/staff.service.ts
import { Auth, BadRequest, Injectable, RoleNotDefined } from "@palbase/backend";

@Injectable()
export class StaffService {
  async promote(userId: string, role: string): Promise<string[]> {
    try {
      // Idempotent: assigning a role they already hold leaves one row.
      await Auth.assignRole(userId, role);
    } catch (e) {
      if (e instanceof RoleNotDefined) {
        throw new BadRequest({ fields: [{ field: "role", message: `no role named "${e.role}"` }] });
      }
      throw e;
    }
    return Auth.rolesOf(userId);
  }
}

Three methods and no more: assignRole, revokeRole and rolesOf. They throw rather than returning an envelope, deliberately — two of the three answer void, so a refusal that came back as a value would be indistinguishable from a write that happened. RoleNotDefined carries the name it was asked for, on e.role, because a role's definition is committed to git: a miss is a typo in your code, not a runtime condition a retry fixes. Every other refusal arrives as the transport's own error with the platform's code on it.

These calls ride the service-role credential, which is why they exist only on the backend: the routes behind them refuse anon and authenticated alike, and that is what stops an end user granting themselves the permission the rest of the feature gates on. Check the caller's right to promote somebody before calling — inside Auth, your own code is the only authorization layer left.

Confirmed email addresses

auth: { verifiedEmail: true } is a real route option and the engine really enforces it — an unverified caller is refused with 403 email_not_verified ("A verified email address is required") before the handler runs.

Warning: it reads the token's email_verified claim, so it says what was true when that token was minted. A user who confirms mid-session keeps being refused until their next token — which is a refresh away, not a sign-in: POST /auth/token/refresh re-reads the row when it mints, and the mobile SDKs spend that refresh for you (iOS from Palbe 0.57.0, Android from 2.1.0). Any other caller — a web client, a script, a server-to-server integration — has to do it itself. verifiedEmail fences a whole route; for a partial rule read user.emailVerified in the handler instead.

If confirmation should be a condition of signing in at all rather than of reaching one route, make it a login-time rule: palbase auth settings set --confirm-email=true sets confirm_email_required on the Environment and the credential layer then refuses to issue a session until the address is confirmed, which nothing can route around. See Auth Settings.

Auth and the database

The authenticated user does more than fill @User(). Every request opens its transaction by binding the caller's identity as bound parameters, never string-interpolated SQL:

select set_config('role', $1, true),
       set_config('search_path', 'public', true),
       set_config('request.jwt.claims', $2, true)

role is backend_authenticated — signed in or not, that is the role a backend request runs as — and request.jwt.claims is the verified claim set your Row-Level Security policies read through auth.uid(). The binding is transaction-scoped, so it cannot leak into another request.

Database.$asService() opens a second transaction on a second connection, bound to backend_service_role (NOLOGIN BYPASSRLS) at its own BEGIN — it is not a role switch around individual statements, because that would let two concurrent operations race each other's role. It carries the same claims: $asService() changes what the caller may touch, not who they are. See Database.

Request metadata injectors

DecoratorInjectsReality
@RequestId()the per-request id, req_…the same id the error envelope reports
@TraceId()a stringthe request id — the same value @RequestId() returns, not a W3C trace id
@Req()the raw Web Requestreq.headers, req.url, req.method, await req.text()
@Client()undefinedexported, but the engine has no branch for it — do not use it

@Req() gives you the standard Request object and nothing more. There is no PBRequest at runtime: no req.input, no req.user, no req.client, no req.file, no req.spanId. The PBRequest type still ships in the SDK's exports and still describes the removed shape — treat it as legacy. Reach for @Req() when you need a header the parameter decorators do not model; take the user from @User(), the body from @Body() and an upload from @UploadedObject() (see Direct Uploads).