Skip to main content

Authentication Overview

pgStack ships a complete authentication system built directly into the proxy. No external auth service required.

How it works​

Token format​

pgStack uses JWT (HS256) signed with your JWT_SECRET.

Access token claims:

{
"sub": "user-uuid",
"email": "user@example.com",
"role": "authenticated",
"iat": 1700000000,
"exp": 1700003600
}

The role claim is the PostgreSQL role that runs your queries. pgStack sets this as the active role before executing REST requests, enabling RLS to work automatically.

Database schema​

Auth data is stored in the pgstack schema:

pgstack.users -- user accounts
pgstack.refresh_tokens -- long-lived refresh tokens
pgstack.oauth_sessions -- OAuth PKCE state
pgstack.email_tokens -- email verification / recovery / magic-link tokens (hashed)

This schema is created by the pgStack bootstrap SQL (docker/init.sql in the dev stack; laid down as your first migration by pgstack init). You should not modify it directly.

Password security​

Passwords are hashed with argon2id using memory-hard parameters. Plain-text passwords are never stored or logged.

Token lifecycle​

Token lifetimes are configurable via environment variables:

  • ACCESS_TOKEN_EXPIRY_SECS — default 3600 (1 hour)
  • REFRESH_TOKEN_EXPIRY_DAYS — default 30

Roles​

pgStack uses three PostgreSQL roles:

RoleDescriptionWho uses it
anonUnauthenticated requestsRequests with ANON_KEY
authenticatedSigned-in usersRequests with a valid JWT
service_roleAdmin, bypasses RLSRequests with SERVICE_ROLE_KEY

Your RLS policies should reference the authenticated role:

-- Only authenticated users can see their own rows
CREATE POLICY "Own rows only"
ON orders FOR ALL
USING (
auth.role() = 'authenticated'
AND user_id = auth.uid()
);

Helper functions​

pgStack installs these helper functions in the auth schema (compatible with Supabase's auth helpers):

-- Current user's UUID (from JWT sub claim)
SELECT auth.uid();

-- Current user's role ('anon', 'authenticated', 'service_role')
SELECT auth.role();

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

-- Full JWT claims as JSONB (empty object if no JWT)
SELECT auth.jwt();

API endpoints​

MethodPathDescription
POST/auth/v1/signupRegister a new user
POST/auth/v1/tokenSign in (grant_type "password") or refresh (grant_type "refresh_token") — grant_type goes in the JSON body, not the query string
GET/auth/v1/userGet current user profile
PATCH/auth/v1/userUpdate user profile
POST/auth/v1/logoutSign out (invalidate refresh token)
POST/auth/v1/anonymousAnonymous sign-in — creates a real guest account (row in pgstack.users, provider='anonymous', synthetic .invalid address) and returns an access token carrying is_anonymous: true plus a refresh token, so the guest survives closing the tab. The PG role is anonymous by default (ANONYMOUS_ROLE=authenticated for the Supabase-compatible flat model). Default-deny: returns 503 unless the proxy runs with ALLOW_ANONYMOUS_SIGNIN=true
POST/auth/v1/recoverRequest password reset email
POST/auth/v1/recover/confirmConfirm password reset with token
POST/auth/v1/magiclinkRequest magic-link email
GET/auth/v1/magiclink/verifyVerify magic-link token
GET/auth/v1/verifyVerify email address
GET/auth/v1/authorize?provider=googleStart OAuth flow
GET/auth/v1/callbackOAuth callback (handled by proxy; also accepts POST for Apple form_post)
GET/auth/v1/admin/usersList users (service_role)
POST/auth/v1/admin/usersCreate user (service_role)
DELETE/auth/v1/admin/users/{id}Delete user (service_role)

Anonymous sign-in from the SDK:

// A guest gets a REAL account: stable id, refresh token, own row in
// pgstack.users. The JWT carries role=anonymous + is_anonymous:true.
const { data, error } = await pgstack.auth.signInAnonymously();
// The access token expires after ANONYMOUS_JWT_TTL (default 3600s) and is
// refreshed automatically through the session — including across reloads.

The guest role​

Guests are a fourth principal between anon and authenticated:

anonanonymous (guest)authenticated
identitynonereal row, stable auth.uid()real row, provider-attached
TO authenticated policiesnono (strict default)yes
TO anon capabilitiesyesyes (guest ≥ visitor)yes

Write guest-aware policies explicitly:

-- guests welcome (per-user data still isolated by auth.uid()):
CREATE POLICY saves_own ON saves FOR ALL TO authenticated, anonymous
USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());

-- members only (the default for any TO authenticated policy):
CREATE POLICY billing_own ON billing FOR SELECT TO authenticated
USING (user_id = auth.uid());

Migrating from Supabase? Set ANONYMOUS_ROLE=authenticated and guests sign in with role=authenticated exactly like there; auth.jwt()->>'is_anonymous' remains the way to tell them apart.

Upgrading a guest to a full account​

The id — and therefore every auth.uid()-keyed row — survives:

// while signed in as a guest:
await pgstack.auth.updateUser({ email: "player@example.com", password: "…" });
// with email verification on, the account flips on confirm;
// with it off, the PATCH itself completes the upgrade.

Setting the password revokes guest-era refresh tokens (a new credential never coexists with old sessions), and freshly minted tokens drop is_anonymous.

Stale guests can be reaped: set ANONYMOUS_ACCOUNT_TTL_DAYS (default 0 = off). Only never-upgraded guests with no live refresh token are deleted.

Next steps​