Skip to main content

PG: Authentication

Before reading, we recommend the PG mode overview for the overall differences between PG mode and legacy mode.

This page is the complete reference for authentication in PG mode, focusing on how it works internally (account storage, JWT, database roles, GRANT / RLS). Common features (SDK integration, login method management, user management, token management) are identical to legacy mode — see the corresponding general documentation. If you are on legacy mode, this page does not apply; read the Authentication overview and other general chapters instead.

In PG mode, CloudBase authentication is built on PostgreSQL: account data lives in the environment's PostgreSQL (under the auth schema), login state travels through the request chain as a JWT, and is parsed into database session variables usable directly in SQL, RLS policies, and column defaults.

What this design buys you — accounts, table privileges, and row-level policies are all expressed in SQL, the same language and semantics as your business queries:

  • Accounts are database tables: accounts no longer live in a separate identity service — they are rows in auth.users and related tables, queryable with plain SQL (SELECT, JOIN, indexes, foreign keys) without going through an identity API.
  • Policies can read the account tables directly: because accounts are in auth.users, RLS policies can JOIN user fields (role, plan, etc.) instead of duplicating them into business tables or querying an identity service first.
  • Login state is visible in SQL: current user ID and role are readable in SQL (e.g. auth.uid()), usable both in RLS policies and as column defaults for "auto-record creator" patterns — no need to pass IDs through business code.
  • AI friendly: accounts, login state, business data, and policies all live as SQL in one database. AI coding assistants can read information_schema and pg_policies to see the real schema and policies, producing queries and RLS rules that match reality instead of guessing.

Quick comparison with legacy mode​

The external authentication interfaces (SDK / console / login methods / user management / token management) are identical in both modes — same SDK, same console entry. Differences are limited to:

DimensionLegacy modePG mode
User storageCentralized identity serviceThe database auth.users table (inside the environment's PostgreSQL, queryable with SQL)
Credential formRefresh Token + Access TokenJWT (Publishable Key / API Key / Access Token — all are JWTs)
Permission modelRoles + policies (console JSON DSL)Three roles (anon / authenticated / service_role) + GRANT + RLS
User typesOrganization members / registered users / anonymous usersRegistered users / anonymous users (no organizational structure)
Login methodsAll supportedAll supported
SDK integrationauth() from @cloudbase/js-sdkSame
Configuration entryConsole "Authentication"Console "Authentication"

The core differences are the four bolded dimensions: user storage, credential form, permission model, user types. Everything else is functionally equivalent and differs only in implementation.

Overall architecture​

Every request in PG mode follows the same "gateway → database" chain:

Client (Web / Mini Program / Cloud Function)
│ Authorization: Bearer <Token>
│ (Publishable Key / Access Token / API Key — all are JWTs)
▼
CloudBase Gateway
│ Parses JWT → extracts role / sub / other claims
│ Injects them into the database session variable request.jwt.claims (JSON string)
▼
PostgREST (automatic RESTful API)
│ Connects to the database with the (internal) gateway connection role
│ SET ROLE anon | authenticated | service_role (based on the JWT role)
▼
PostgreSQL
├─ Layer 1: table-level permission check (GRANT ... TO <role>)
├─ Layer 2: row-level filtering (RLS Policy: USING / WITH CHECK)
└─ schemas:
├─ auth account system (users, etc.)
├─ storage CloudBase storage metadata (buckets / objects)
└─ public business tables

Double-lock principle: an operation succeeds only when both the table-level GRANT and the RLS policy pass. Either layer rejecting returns a permission error.

Where user data lives​

Unlike legacy mode, which stores accounts in a centralized identity service, PG mode stores account data in the environment's PostgreSQL under the auth schema. auth.users is the main user table, holding user ID, email, phone, login methods, and fields such as app_metadata and user_metadata.

The auth.users primary key id is VARCHAR(64) and matches the JWT sub claim exactly.

You can query and join user data directly in SQL:

-- Count all users
SELECT count(*) FROM auth.users;

-- Join a business table to user metadata via user_id
SELECT o.*, u.user_metadata->>'name' AS buyer_name
FROM public.orders o
JOIN auth.users u ON u.id = o.buyer_id;
The console works the same way

The console "User Management" page and the SDK APIs (auth.currentUser / User.update(), etc.) behave identically to legacy mode — see Manage Users. This section focuses on the database-level access path.

Ownership column recommendations for business tables​

Keep ownership columns the same type as auth.users.id (varchar(64); text also works — the two are assignment-compatible in PostgreSQL), and default it to a value read from the JWT:

-- Recommended: varchar(64) ownership columns, optionally referencing auth.users
CREATE TABLE public.orders (
id bigserial PRIMARY KEY,
buyer_id varchar(64) NOT NULL
REFERENCES auth.users(id) -- Optional: foreign key constraint
DEFAULT auth.uid(),
-- ...
);

Whether to add REFERENCES auth.users(id) is up to you: it buys referential integrity, but deleting a user is blocked while rows reference them. In most cases a plain column (no foreign key) is enough.

Three database roles​

Every request in PG mode is mapped to one of the following roles (depending on the credential presented):

RoleCredentialHow to obtainNotes
anonPublishable Key, or an Access Token from anonymous sign-inCreate a Publishable Key in the console / via YUNAPI, or call /auth/v1/signin/anonymouslyJWT role: "anon"; safe to embed in frontends
authenticatedAccess Token issued after sign-inCall /auth/v1/signin (password / third-party)JWT role: "authenticated" with a sub (user ID)
service_roleAPI KeyCreate an API Key in the console / via YUNAPIJWT role: "service_role"; has BYPASSRLS, never expose to frontends
danger

The API Key (corresponding to service_role) bypasses RLS and must never appear in frontend code, mini programs, or apps. Use it only on the backend (cloud functions / CloudRun / your own services).

Conceptual differences from legacy roles​

DimensionLegacy modePG mode
Role definitionApplication-layer concept (admin / registered user / anonymous / custom roles)PostgreSQL database roles (anon / authenticated / service_role)
Where authorization runsConsole rule engineDatabase engine (GRANT + RLS)
Organization membersSupported, assignable by department / custom roleNot supported (no organizational structure in PG mode). Tenant/department info can go into app_metadata and be read by RLS policies

Credentials and JWT​

All credentials in PG mode — Publishable Key, API Key, Access Token — are the same kind of thing: a JWT. The gateway picks the database role from the JWT role field.

Key types at a glance​

Key typeRoleUse caseExpiresSafety
Publishable KeyanonDirect frontend access, anonymous capabilities backed by RLS❌ Never✅ Safe to embed in frontend code
Access Token (issued after sign-in)authenticatedSigned-in users accessing data✅ Governed by Token management policy (default 2 hours)Managed and refreshed automatically by the SDK
API Keyservice_roleBackend admin operations, bulk imports, background jobs❌ Never⚠️ Never in the frontend; inject via environment variables
tip

Publishable Key and API Key are fixed JWT tokens — the gateway only reads the role field. They never expire until explicitly revoked or rotated.

Creating a Publishable Key / API Key​

Both keys can be created in two ways:

  1. Console: sign in to the CloudBase console → Environment settings → API keys
  2. Tencent Cloud API (YUNAPI): call CreateApiKey
# Create a Publishable Key
POST https://tcb.tencentcloudapi.com
X-TC-Action: CreateApiKey
X-TC-Version: 2018-06-08
Content-Type: application/json

{ "EnvId": "<envId>", "KeyType": "publish_key" }

# Create an API Key
{ "EnvId": "<envId>", "KeyType": "api_key" }

JWT example for an authenticated user (after sign-in)​

{
"iss": "https://<envId>.api.tcloudbasegateway.com/auth/v1",
"sub": "8d7011e4-2af7-41d7-adb1-a0486499eee3",
"aud": "<envId>",
"exp": 1773559640,
"iat": 1773556040,
"role": "authenticated",
"scope": "openid",
"nonce": "...",
"at_hash": "...",
"name": "ulyssesliu",
"project_id": "<envId>",
"app_metadata": { "provider": "password", "providers": ["password"] },
"user_metadata": { "name": "ulyssesliu" },
"user_type": "external",
"client_type": "client_user",
"is_system_admin": false,
"is_anonymous": false
}

JWT example for a Publishable Key (anon)​

{
"iss": "https://<envId>.api.tcloudbasegateway.com/auth/v1",
"sub": "anon",
"aud": "<envId>",
"role": "anon",
"scope":"anonymous",
"name": "Anonymous",
"project_id": "<envId>",
"meta": { "platform": "PublishableKey" },
"app_metadata": { "provider": "anonymous", "providers": ["anonymous"] },
"user_metadata": { "name": "Anonymous" },
"is_anonymous": true
}

Note: the Publishable Key's sub is the fixed string "anon", not a real user ID. After calling /auth/v1/signin/anonymously for anonymous sign-in, the resulting Access Token still has role: anon but carries a real sub (the anonymous user ID), which can be used as a business-table ownership value.

JWT example for an API Key (service_role)​

{
"aud": "<envId>",
"role": "service_role",
"project_id": "<envId>",
"meta": { "platform": "ApiKey" },
"app_metadata": { "provider": "apikey", "providers": ["apikey"] },
"user_type": "",
"client_type": "client_server",
"is_system_admin": true
}

Key claims explained​

ClaimMeaning
subCurrent user ID (the source for ownership column values). Fixed "anon" for Publishable Keys; a real ID after anonymous sign-in
roleDatabase role: anon / authenticated / service_role
audAudience, equal to project_id (the envId)
issIssuer
exp / iatExpiry / issued-at (Unix timestamps)
is_anonymousWhether anonymous (true for both Publishable Keys and anonymous sign-in)
is_system_adminWhether system admin (true for service_role)
meta.platformToken source: PublishableKey / ApiKey / empty (user sign-in)
app_metadataSystem-level metadata (login method, provider, etc.)
user_metadataUser-defined metadata (nickname, avatar, etc.)

Reading login state in SQL​

PG mode ships helper functions under the auth schema for use directly in SQL, RLS policies, and column defaults:

FunctionReturnsDescription
auth.jwt()jsonbThe full JWT claims (low-level). Read app_metadata, user_metadata, or any other field from it
auth.uid()textCurrent user ID; equivalent to auth.jwt() ->> 'sub'
auth.role()textCurrent database role (anon / authenticated / service_role); equivalent to auth.jwt() ->> 'role'
auth.email()textCurrent user email; equivalent to auth.jwt() ->> 'email'
-- Current user ID
SELECT auth.uid();

-- Current role
SELECT auth.role();

-- Current user email
SELECT auth.email();

-- Full claims
SELECT auth.jwt();

-- Read a custom JWT field (e.g. tenant ID)
SELECT auth.jwt() -> 'app_metadata' ->> 'tenant_id';
How it works

Before forwarding a request to PostgREST, the gateway injects the JWT claims into the database session GUC variable request.jwt.claims (a JSON string). auth.jwt() is a thin wrapper over that GUC variable, and auth.uid() / auth.role() / auth.email() are syntax sugar for auth.jwt() ->> '<field>'.

The preset function definitions:

-- Extract full claims from the JWT (low-level function)
-- Only uses request.jwt.claims (plural) — the GUC key name set by PostgREST
CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb
LANGUAGE sql STABLE
AS $$
select
nullif(current_setting('request.jwt.claims', true), '')::jsonb
$$;

-- Extract the user ID (sub) — syntax sugar for auth.jwt() ->> 'sub'
CREATE OR REPLACE FUNCTION auth.uid() RETURNS text
LANGUAGE sql STABLE
AS $$
select
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::text
$$;

-- Extract the role — syntax sugar for auth.jwt() ->> 'role'
CREATE OR REPLACE FUNCTION auth.role() RETURNS text
LANGUAGE sql STABLE
AS $$
select
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role')::text
$$;

-- Extract the email — syntax sugar for auth.jwt() ->> 'email'
CREATE OR REPLACE FUNCTION auth.email() RETURNS text
LANGUAGE sql STABLE
AS $$
select
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email')::text
$$;

Writing current_setting(...) directly also works, but prefer auth.uid() and friends — shorter, and consistent with the PostgREST / Supabase ecosystem conventions.

Table-level permissions (GRANT)​

Use standard SQL to control what each role can do to a table:

-- Allow anonymous users to read the products table
GRANT SELECT ON public.products TO anon, authenticated;

-- Allow signed-in users to write orders
GRANT INSERT, SELECT, UPDATE ON public.orders TO authenticated;

-- Sequences need grants too (auto-increment IDs on INSERT)
GRANT USAGE, SELECT ON SEQUENCE public.orders_id_seq TO authenticated;

Note: service_role can access all tables by default (BYPASSRLS). anon and authenticated must be granted access explicitly.

Row-level permissions (RLS Policy)​

Once RLS is enabled on a table, every access must pass a policy. Rows not matched by any ALLOW policy are denied by default.

Auto-bind the current user (ownership column best practice)​

CREATE TABLE public.orders (
id serial PRIMARY KEY,
buyer_id varchar(64) NOT NULL
DEFAULT auth.uid(),
amount numeric(10,2),
created_at timestamptz DEFAULT now()
);

The key: buyer_id defaults to the JWT sub and does not depend on frontend input, eliminating identity forgery at the source.

SELECT and INSERT policies​

-- Enable RLS
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;

-- Users can read only their own orders
CREATE POLICY orders_select_own ON public.orders
FOR SELECT TO authenticated
USING (
buyer_id = auth.uid()
);

-- Users can insert only their own orders (anti-forgery)
CREATE POLICY orders_insert_own ON public.orders
FOR INSERT TO authenticated
WITH CHECK (
buyer_id = auth.uid()
);

UPDATE policies need both USING and WITH CHECK​

CREATE POLICY orders_update_own ON public.orders
FOR UPDATE TO authenticated
USING (
buyer_id = auth.uid()
)
WITH CHECK (
buyer_id = auth.uid()
);

USING controls "which rows can be operated on"; WITH CHECK controls "whether the updated values are valid". Together they prevent a user from hijacking other users' data by rewriting buyer_id in an UPDATE.

Common scenarios​

Public product list + private orders​

-- Anyone can read products
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
CREATE POLICY products_public_read ON public.products
FOR SELECT TO anon, authenticated
USING (true);

-- Orders visible only to their owner
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY orders_owner_only ON public.orders
FOR ALL TO authenticated
USING (
buyer_id = auth.uid()
)
WITH CHECK (
buyer_id = auth.uid()
);

Multi-tenant SaaS​

-- Business tables carry tenant_id; the policy restricts each user to their own tenant
CREATE POLICY tenant_isolation ON public.records
FOR ALL TO authenticated
USING (
tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')
);

For more RLS patterns (READONLY / PRIVATE / ADMINWRITE / ADMINONLY, etc.), see the RLS pattern library.

Login methods and the auth API​

Login methods and SDK integration are identical to legacy mode — same @cloudbase/js-sdk, same auth() APIs, same console configuration. For each sign-in type (anonymous / username-password / SMS / email / third-party), see the general docs SDK initialization and sign-in; enabling and disabling methods is covered in Manage login methods.

In PG mode you can also call the HTTP API directly to sign in (closer to the PostgREST style, handy in SQL clients, self-built backends, or AI toolchains) — see the /auth/v1/* reference.

Accessing the database from cloud functions​

Two options inside a cloud function:

Option 1: pass through the user's access_token (subject to RLS)​

// When the frontend calls the cloud function, the SDK passes the login state through automatically;
// the function can read the access_token from request headers and call the REST API with it,
// so the database is accessed as the user and RLS applies.
const res = await fetch(`${REST_BASE}/v1/rdb/rest/orders?select=*`, {
headers: { Authorization: `Bearer ${userAccessToken}` },
});

Option 2: use the API Key (bypasses RLS, admin identity)​

// Backend environments only (cloud functions, CloudRun, etc.)
// The API Key is usually injected via environment variables
const res = await fetch(`${REST_BASE}/v1/rdb/rest/orders?select=*`, {
headers: { Authorization: `Bearer ${process.env.CLOUDBASE_API_KEY}` },
});
warning

Never pass the API Key through to the frontend in a cloud function response.

Appendix: database roles at a glance​

This appendix is for developers who want the underlying details. Application development only ever needs anon / authenticated / service_role; executePGSql runs as cloudbase_postgres by default (see the executePGSql API). All other roles are platform-internal, maintained by the platform, and never exposed to users — they are not named here.

RolePurposeKey attributes
anonApplication: anonymousNOLOGIN NOINHERIT, statement_timeout=3s
authenticatedApplication: signed-inNOLOGIN NOINHERIT, statement_timeout=8s
service_roleApplication: adminNOLOGIN NOINHERIT BYPASSRLS
cloudbase_postgresApplication-layer admin role (default identity of executePGSql, converged by privilege-hardening)NOSUPERUSER, INHERIT, BYPASSRLS; may create and alter tables under public, while auth / storage / extensions are blocked by REVOKE CREATE ON SCHEMA

Beyond these, the platform maintains several modular admin roles (one per subsystem: auth, storage, cloud functions, ETL, etc.) plus a gateway connection role that strictly isolates each subsystem's privileges. These roles are not exposed to users and application development never needs to know them.

Role switching chain​

Client request
│ Authorization: Bearer <JWT>
▼
Gateway parses the JWT and extracts the role field
│
▼
PostgREST connects to the database with the (internal) gateway connection role
│
▼
SET ROLE anon | authenticated | service_role (based on the JWT)
│
▼
Business SQL runs (double-checked by GRANT + RLS)

Design principles​

  • Separation of duties: each subsystem has its own admin role, so one vulnerability cannot affect everything
  • Application/system isolation: anon / authenticated / service_role cannot sign in directly (NOLOGIN) and only come into effect through gateway role switching — no direct SQL connection can bypass the permission model
  • Timeout protection: anon gets statement_timeout=3s, authenticated gets 8s; the gateway connection role additionally sets lock_timeout=8s to keep slow queries from dragging down the instance
  • cloudbase_postgres DDL boundary: since 2026-08-18, the two Event Triggers that used to guard system tables have been removed, fully aligning with Supabase's postgres capabilities. The role can create, alter, and drop tables under the public schema; auth / storage / extensions are restricted by REVOKE CREATE ON SCHEMA and accept no new objects. TRUNCATE and TRIGGER privileges on system tables are also revoked — use DELETE to clear data. Platform admin accounts are not exposed to users; no entry point executes SQL on a user's behalf with an internal identity

See also​