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 — mints a short-lived role=anon JWT (random sub, no DB row, no refresh token). 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:

// Lets public-read demo pages satisfy REQUIRE_AUTHENTICATED_WS
// without embedding the raw anon key as a fake credential
const { data, error } = await pgstack.auth.signInAnonymously();
// data.session.access_token expires after ANONYMOUS_JWT_TTL (default 3600s);
// there is no refresh token

Next steps