Skip to content

Authentication

A session is a signed JWT, sitting in a secure, httponly cookie. Its only field rel actually cares about is role — the Postgres role every query and route call in that session runs as, applied with SET ROLE/SET LOCAL ROLE on the connection. Authentication in rel is really just "how does the right value end up in that one claim" — there's no separate authorization layer to configure on top; once role is set, Postgres's own grants and row-level security decide what the request can actually do.

Anonymous access works the same way: with no valid session, a request runs as pg.query.anonymous_role (default ~anonymous) — a real Postgres role that must exist in the database, or anonymous access is disabled outright and every unauthenticated request gets 401. There's no implicit "public" state in between.

Minting a session

Any full-control route or middleware function (the two-OUT-column shape — see [HTTP routes

Function prototype](index.md#function-prototype)) can start a session by setting a jwt

field on its response:

create function auth.login(req jsonb, out resp jsonb, out content jsonb)
returns record
language plpgsql
security definer
as $$
declare
  matched_user auth.users;
begin
  select * into matched_user from auth.users
  where auth.users.username = (req->'body')->>'username'
    and auth.users.password_hash = crypt((req->'body')->>'password', auth.users.password_hash);

  if matched_user is null then
    raise exception 'Invalid credentials' using errcode = 'RS401';
  end if;

  resp := jsonb_build_object('jwt', jsonb_build_object(
    'role', matched_user.role,
    'user_id', matched_user.id,
    'plan', matched_user.plan
  ));
  content := null;
end;
$$;
comment on function auth.login(jsonb) is 'route:: path: "/auth/login", method: "POST"';

Called with {"username": "...", "password": "..."} as the JSON body. (Naming the path /auth/login here is just an example, not special — actual /auth/* is a reserved prefix rel itself serves for OIDC/SAML, and a user-declared route can never sit there; a real login function lives at whatever path you choose.)

role is the only claim rel itself reads back out of the JWT (to SET ROLE with); every other key in the object — user_id/plan above, or anything else — is just carried along as a custom claim, verbatim, for your own functions to read later via req.jwt, or via a session-revocation middleware (see Session lifecycle below). rel fills in iat, exp, and auth_time itself; setting either of those, or auth_time, on the response's jwt object has no effect — they're always overwritten. Setting jwt: null on a response clears the session (logout).

By default, any route or middleware function can set jwt and authenticate the caller as any role; restrict that with http.functions.allowed_auth (a regexp against the function's fully qualified name) once you have real login functions to point it at — e.g. ^auth\.. A terminating middleware setting jwt is gated by its own identifier; a pass-through middleware setting jwt ahead of a route is gated by that route's identifier instead (the route's own response is what actually renders) — and ahead of /rel or a static file, by no identifier at all, which allowed_auth can only ever match by being left unrestricted. Have a middleware gating those two paths terminate, rather than merely pass jwt through, if allowed_auth is restricted.

OpenID Connect and SAML

For OIDC and SAML, rel drives the protocol itself — you only write the function that turns a verified identity assertion into a role. Configure one named entry per provider:

[openid.google]
issuer = "https://accounts.google.com"
# client_id / client_secret default to /secrets/openid-google.id and
# /secrets/openid-google.secret — drop the two files there and skip these lines
callback_function = "auth.sso_callback"

[saml.corp_okta]
idp_metadata_url = "https://corp.okta.com/app/.../sso/saml/metadata"
callback_function = "auth.sso_callback"

<name> (google, corp_okta above) is yours to choose — it's not tied to any provider's brand, so two Google Workspace tenants, or an OIDC and a SAML connection to the same IdP, are just two differently-named entries. Each one gets its own routes:

  • GET /auth/oidc/{name}/login / GET /auth/oidc/{name}/callback
  • GET /auth/saml/{name}/login / POST /auth/saml/{name}/acs
  • GET /auth/saml/{name}/metadata — this deployment's SP metadata, to hand to the IdP admin

Both protocols converge on the same callback function signature — one payload argument, returning a single jsonb value shaped like HttpResponse plus its own content/template_data keys (this one call site predates, and isn't a declared route itself, so it keeps its own single-envelope shape rather than the two-OUT-column one) — regardless of which protocol produced it:

create function auth.sso_callback(payload jsonb) returns jsonb
language plpgsql
security definer
as $$
declare
  matched_role text;
begin
  -- payload.identity.protocol is 'oidc' or 'saml'; payload.identity.claims
  -- holds the ID token's claims (OIDC) or every assertion attribute (SAML,
  -- each value a string[] even when single-valued). Look up whatever
  -- identifies this user in your own schema and decide a role from it.
  select role into matched_role
  from auth.users where auth.users.email = payload->'identity'->'claims'->>'email';

  if matched_role is null then
    raise exception 'No account for this identity' using errcode = 'RS401';
  end if;

  return jsonb_build_object('jwt', jsonb_build_object('role', matched_role));
end;
$$;

payload is shaped:

interface SsoCallback {
  jwt: JWT | null      // the browser's OWN current session, if any — see below
  identity: {
    protocol: "oidc" | "saml"
    name: string        // the configured openid.<name>/saml.<name> entry
    claims: { [key: string]: unknown }
    access_token?: string   // OIDC only, when the token exchange returned one
    refresh_token?: string
  }
  state: unknown        // /login's own query string, decoded — see below
}

rel doesn't interpret an email, a groups claim, or a SAML attribute for you — mapping identity to a role is entirely your call, which is what keeps rel from assuming any particular user-table shape. openid.<name>.callback_function/saml.<name>.callback_function each fall back to http.functions.sso_callback when unset, so one function can serve every provider if your role-mapping logic doesn't need to vary per provider.

The browser's own current session : jwt

payload.jwt is whatever session the browser already had before this SSO round trip started — read directly off the callback request itself, the exact same way any other authenticated request's session is, never sent to or through the identity provider. This is what makes account linking possible: a callback function can tell "this browser is already logged in as user X, and just finished SSO as identity Y" (payload.jwt non-null) apart from a fresh, anonymous login (payload.jwt null), and decide to link the two rather than always minting a brand-new session.

One thing worth knowing before relying on it: payload.jwt reliably arrives for OIDC's callback (a GET, and rel's JWT cookie is SameSite=Lax by default — which does survive a cross-site top-level GET navigation), but not for SAML's /acs under the same default — /acs is always a POST, and SameSite=Lax cookies aren't sent on a cross-site POST. A callback function driven by SAML will see payload.jwt: null even for an actually-logged-in browser, unless the deployment sets jwt.same_site = None.

Errors

Two distinct error sources exist on /auth/oidc/* and /auth/saml/*, and a client tells them apart the same way it tells apart any other code/X-Rel-Errorcode pair (see Error responses) — not by HTTP status alone, since both can plausibly render as a 4xx or 5xx:

  • The callback function's own rejectionraise exception ... using errcode = 'RSxxx', the same convention any other route function uses, once a request has actually reached the database.
  • Protocol-level failures rel itself detects, before the callback function ever runs — these get their own rel-internal codes:
code Status Meaning
SSO_NOT_READY 503 discovery/IdP metadata hasn't resolved yet
SSO_BAD_REQUEST 400 malformed callback request — missing code, unparseable form
SSO_BAD_STATE 400 missing or mismatched OAuth2 state
SSO_BAD_NONCE 400 ID token nonce doesn't match the one this login minted
SSO_TOKEN_EXCHANGE_FAILED 502 the issuer's token endpoint rejected the exchange
SSO_NO_ID_TOKEN 502 token response carried no id_token
SSO_INVALID_ID_TOKEN 502 id_token failed signature/issuer/audience verification
SSO_USERINFO_FAILED 502 fetch_userinfo's own call to the issuer failed
SSO_SAML_INVALID_RESPONSE 400 SAML response/assertion failed to parse or verify
SSO_INTERNAL 500 rel's own logic failed — state/nonce generation, encoding

Passing state through login

/login accepts a plain query string, decoded the same structural way GET /rel's own query string is (dotted keys nest), and handed back to the callback function verbatim as payload.statenull if /login got no query string at all. A link to /auth/oidc/google/login?return_to=/dashboard becomes payload.state = {"return_to": "/dashboard"} by the time the callback function runs, round-tripped through whichever mechanism the protocol itself provides for exactly this (OIDC's state parameter, alongside rel's own CSRF token ; SAML's RelayState, otherwise unused).

Treat state as untrusted input, the same as any query string a client controls. Nothing stops a third party from linking a victim straight to /login?... with a query string of their own choosing — this endpoint is unauthenticated and was never meant to be secret. Two consequences:

  • Never redirect to a state-supplied URL without validating it first. A return_to-style value is the obvious thing to put in state, and blindly redirecting to whatever it says is a textbook open redirect — restrict it to a relative path, or an allowlist of known hosts, before using it.
  • Never put a secret in state. Both OIDC's state and SAML's RelayState round-trip through the identity provider itself, and are visible in redirect URLs, browser history, and IdP-side logs along the way — treat it exactly as visible as anything else the browser's address bar shows.

A few things worth knowing before wiring this up in production:

  • http.public_host must be set — a bare host like app.example.com, no scheme — so rel can build the exact redirect_uri/ACS URL each provider needs registered ahead of time.
  • SAML needs a stable certificate. rel generates a self-signed one on first boot if saml.certificate_path/saml.private_key_path don't already exist, and reuses it silently on every later boot — swapping it breaks every IdP that was told to trust the old one, so back those files up the same way you'd back up any other credential. That one certificate is shared across every saml.<name> entry by default; an entry needing an IdP to trust a certificate of its own sets saml.<name>.certificate_path/private_key_path — same generate-if-missing behavior, scoped to that entry alone.
  • A misconfigured or not-yet-reachable provider doesn't fail startup — rel logs a warning and serves 503 from that provider's endpoints until it becomes reachable, so one broken IdP connection doesn't take the rest of the deployment down.

Session lifecycle

A verified token gets renewed automatically once more than jwt.renew_after (default half) of its own lifespan has elapsed — role and auth_time never change on renewal, only iat/exp. Two separate limits bound a session: jwt.max_age bounds any single token (short, so a stolen cookie is only useful briefly), and jwt.max_session_age bounds how long renewal can keep extending the session overall, measured from the original auth_time — once it's exceeded, the session can't be renewed anymore and the user has to fully re-authenticate.

For revocation before either of those would naturally expire it — a password change, a ban, an admin-triggered logout — declare a session-checking middleware at whatever prefix needs it (/ to cover everything, including /rel). Unlike the old check_session mechanism, middleware runs after the role switch, as the request's own already-resolved role — and terminates with {status: 401, jwt: null} to reject and clear the session in one step, rather than merely raising:

create function auth.check_session(req jsonb, out resp jsonb, out content jsonb)
returns record
language plpgsql
security definer
as $$
begin
  if exists (select 1 from auth.revoked_sessions where user_id = (req->'jwt'->>'user_id')::bigint) then
    resp := jsonb_build_object('status', 401, 'jwt', null);
  else
    resp := null;
  end if;
  content := null;
end;
$$;
comment on function auth.check_session(jsonb) is 'route:: path: "/", middleware: true';

Terminating ({status: 401, jwt: null}) clears the session and forces re-authentication; returning resp := null (a genuine SQL NULL, not the JSON literal) lets the request proceed unchanged. rel doesn't require any particular claim (a jti, a session id) for this to work — how you identify "this session" in your own revoked-sessions table is up to whatever custom claims your login function put on the JWT. Since this middleware runs as the request's own role rather than the primary connection, grant it EXECUTE for every role that should reach anything under its prefix, including the anonymous role — see Deployment checklist below.

Claims as a Postgres setting

The claims object is also available as a plain Postgres setting, current_setting('rel.jwt.claims', true)::jsonb — the same shape as req.jwt (null for an anonymous request), readable from any function that runs as part of handling the request: an RLS policy, a trigger, a function called deeper down that doesn't have the claims threaded through as an argument. It's set right alongside the role switch, so it's there for every route/middleware function and every /rel query/write.

create policy own_rows_only on documents
  using (owner_id = (current_setting('rel.jwt.claims', true)::jsonb->>'user_id')::bigint);

The true second argument matters: without it, current_setting raises an error instead of returning null on a connection where it was never set — worth keeping even though rel itself always sets it, since a direct psql session (superuser, migrations) never goes through this path at all.

Deployment checklist

SET ROLE only succeeds when the connecting role is already a member of the role being switched to — grant it explicitly for every role any JWT in the deployment may carry, including the anonymous one:

grant "~anonymous" to rel_user;
grant "editor" to rel_user;
grant "admin" to rel_user;

That grants rel_user (pg.user — see Best practices ## Keep pg.user's own privileges minimal) membership — the ability to SET ROLE into ~anonymous at all. A route function still needs its own EXECUTE grant, directly to ~anonymous (or to a role it belongs to), before an anonymous request can reach it:

grant execute on function guest_login(jsonb) to "~anonymous";

Skipping a grant on a route doesn't fail at startup : rel caches, once per discovered route at introspection/reload time, whether the anonymous role can actually reach it (see below), and rejects an anonymous request the anonymous role can't reach with 401 before the request body is even read. A middleware function has no such precheck, since it runs as the request's already-resolved role rather than pg.user itself — a missing EXECUTE grant there surfaces as an ordinary Postgres permission-denied 403 on the first real request that reaches it, the same as any other route call missing a grant.

When anonymous access is enabled, rel caches, once per discovered route or middleware at introspection/reload time, whether the anonymous role can actually reach it — schema USAGE plus an explicit EXECUTE grant, to the anonymous role itself or to a role it belongs to. (That cache backs the route-level 401 precheck above; middleware, as noted, isn't precomputed this way, since middleware permission failures show up as 403s instead.)

This check deliberately doesn't credit EXECUTE the moment it's merely inherited from a grant to PUBLIC — Postgres grants EXECUTE to PUBLIC by default on create function unless default privileges were changed, so crediting it here would silently authorize anonymous access to any route function nobody explicitly decided the anonymous role should reach. Grant EXECUTE on any route function you actually want the anonymous role to call, the same way you grant it any other role. Independently of this check, rel also warns (non-fatally) at introspection/reload for every route or middleware reachable by PUBLIC at all, regardless of caller — see Best practices for turning that default off entirely.

Configuration reference

Key Default Purpose
jwt.secret generated into /secrets/jwt-secret JWT signing secret
jwt.cookie_name accesstoken cookie carrying the JWT
jwt.algorithm HS256 one of HS256/HS384/HS512
jwt.same_site Lax SameSite on the JWT cookie
jwt.max_age 1800 (30 min) freshly-minted token lifetime, seconds
jwt.renew_after 0.5 fraction of lifetime elapsed before auto-renewal
jwt.max_session_age 604800 (7 days) hard ceiling on total session lifetime
pg.query.anonymous_role ~anonymous role for requests with no valid session
http.functions.allowed_auth unset (unrestricted) regexp restricting which functions may set jwt
http.public_host unset this deployment's externally-reachable host, required for OIDC/SAML
openid.<name>.issuer required; the OIDC issuer URL
openid.<name>.scopes openid, email, profile requested OIDC scopes
openid.<name>.fetch_userinfo false also call the userinfo endpoint after token exchange
openid.<name>.callback_function / saml.<name>.callback_function unset, falls back to http.functions.sso_callback function invoked with the verified claims
saml.<name>.idp_metadata_url required; fetched once at startup
saml.<name>.force_signed_requests true sign outgoing AuthnRequests
saml.certificate_path / saml.private_key_path generated on first boot this deployment's SP certificate/key, shared across every saml.<name> entry
saml.<name>.certificate_path / saml.<name>.private_key_path unset, inherits the shared pair above per-entry override, generated on first boot the same way if set and missing

See Configuration reference for how these values, secrets included, get supplied across environment variables, config files, and generated files — including a couple of keys not repeated here (openid.<name>.client_id/client_secret, openid.<name>.public_host/saml.<name>.public_host) and every other key rel understands.