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.usersand 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 canJOINuser 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_schemaandpg_policiesto 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:
| Dimension | Legacy mode | PG mode |
|---|---|---|
| User storage | Centralized identity service | The database auth.users table (inside the environment's PostgreSQL, queryable with SQL) |
| Credential form | Refresh Token + Access Token | JWT (Publishable Key / API Key / Access Token — all are JWTs) |
| Permission model | Roles + policies (console JSON DSL) | Three roles (anon / authenticated / service_role) + GRANT + RLS |
| User types | Organization members / registered users / anonymous users | Registered users / anonymous users (no organizational structure) |
| Login methods | All supported | All supported |
| SDK integration | auth() from @cloudbase/js-sdk | Same |
| Configuration entry | Console "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 "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):
| Role | Credential | How to obtain | Notes |
|---|---|---|---|
anon | Publishable Key, or an Access Token from anonymous sign-in | Create a Publishable Key in the console / via YUNAPI, or call /auth/v1/signin/anonymously | JWT role: "anon"; safe to embed in frontends |
authenticated | Access Token issued after sign-in | Call /auth/v1/signin (password / third-party) | JWT role: "authenticated" with a sub (user ID) |
service_role | API Key | Create an API Key in the console / via YUNAPI | JWT role: "service_role"; has BYPASSRLS, never expose to frontends |
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
| Dimension | Legacy mode | PG mode |
|---|---|---|
| Role definition | Application-layer concept (admin / registered user / anonymous / custom roles) | PostgreSQL database roles (anon / authenticated / service_role) |
| Where authorization runs | Console rule engine | Database engine (GRANT + RLS) |
| Organization members | Supported, assignable by department / custom role | Not 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 type | Role | Use case | Expires | Safety |
|---|---|---|---|---|
| Publishable Key | anon | Direct frontend access, anonymous capabilities backed by RLS | ❌ Never | ✅ Safe to embed in frontend code |
| Access Token (issued after sign-in) | authenticated | Signed-in users accessing data | ✅ Governed by Token management policy (default 2 hours) | Managed and refreshed automatically by the SDK |
| API Key | service_role | Backend admin operations, bulk imports, background jobs | ❌ Never | ⚠️ Never in the frontend; inject via environment variables |
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:
- Console: sign in to the CloudBase console → Environment settings → API keys
- 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
subis the fixed string"anon", not a real user ID. After calling/auth/v1/signin/anonymouslyfor anonymous sign-in, the resulting Access Token still hasrole: anonbut carries a realsub(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
| Claim | Meaning |
|---|---|
sub | Current user ID (the source for ownership column values). Fixed "anon" for Publishable Keys; a real ID after anonymous sign-in |
role | Database role: anon / authenticated / service_role |
aud | Audience, equal to project_id (the envId) |
iss | Issuer |
exp / iat | Expiry / issued-at (Unix timestamps) |
is_anonymous | Whether anonymous (true for both Publishable Keys and anonymous sign-in) |
is_system_admin | Whether system admin (true for service_role) |
meta.platform | Token source: PublishableKey / ApiKey / empty (user sign-in) |
app_metadata | System-level metadata (login method, provider, etc.) |
user_metadata | User-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:
| Function | Returns | Description |
|---|---|---|
auth.jwt() | jsonb | The full JWT claims (low-level). Read app_metadata, user_metadata, or any other field from it |
auth.uid() | text | Current user ID; equivalent to auth.jwt() ->> 'sub' |
auth.role() | text | Current database role (anon / authenticated / service_role); equivalent to auth.jwt() ->> 'role' |
auth.email() | text | Current 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';
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_rolecan access all tables by default (BYPASSRLS).anonandauthenticatedmust 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_iddefaults to the JWTsuband 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}` },
});
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;executePGSqlruns ascloudbase_postgresby 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.
| Role | Purpose | Key attributes |
|---|---|---|
anon | Application: anonymous | NOLOGIN NOINHERIT, statement_timeout=3s |
authenticated | Application: signed-in | NOLOGIN NOINHERIT, statement_timeout=8s |
service_role | Application: admin | NOLOGIN NOINHERIT BYPASSRLS |
cloudbase_postgres | Application-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_rolecannot sign in directly (NOLOGIN) and only come into effect through gateway role switching — no direct SQL connection can bypass the permission model - Timeout protection:
anongetsstatement_timeout=3s,authenticatedgets8s; the gateway connection role additionally setslock_timeout=8sto keep slow queries from dragging down the instance cloudbase_postgresDDL 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 thepublicschema;auth/storage/extensionsare restricted byREVOKE CREATE ON SCHEMAand accept no new objects.TRUNCATEandTRIGGERprivileges on system tables are also revoked — useDELETEto 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
- PostgreSQL database — architecture and permission model
- PostgreSQL data permissions (RLS in depth)
- RLS pattern library
- Postgres-native mode storage
- Authentication overview — general authentication docs
- Manage users — general user management
- Token management — dual tokens, validity, max sessions (both modes)
- Access control — legacy mode roles + policies