PgPaw uses Postgres as the access-control source of truth. PgPaw verifies who the caller is, then Postgres decides what that caller can read.
That split is important:
- JWT verification maps the request to a Postgres role and claims JSON.
- Postgres grants and RLS policies decide row visibility.
- PgPaw uses the same catalog state to decide whether a query is safe for the public shared snapshot cache.
Scope Decision
PgPaw classifies each query as public or private after SQL classification.
For every touched table, PgPaw reads the replicated Postgres catalog:
-
A table is public when RLS is disabled and
PUBLIChasSELECT. -
A table is private when RLS is enabled or
PUBLIClacksSELECT. - A query is private if any touched table is private.
- Unknown tables fail closed and are treated as private.
Public queries do not require a token. Private queries require a bearer token.
If an Authorization header is present, PgPaw parses
it before classifying the query. That means a public query with a
malformed token still returns 401. For public data,
omit the header unless you intentionally want authenticated
requests.
Public Query Response
Public means the result is safe to share across users. PgPaw stores the result in its shared in-process query cache and returns a cursor:
HTTP/1.1 303 See Other
Location: /q/{hash}/{version}
Cache-Control: no-store
The cursor response is cacheable by browsers, proxies, and CDNs:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: {hash}:{version}
Cache-Control: public, max-age=259200
Only make tables public when every row in the query is safe for
every caller that can reach PgPaw. If a table contains
user-specific data, enable RLS or revoke
PUBLIC SELECT before serving it through PgPaw.
See Cache-Control for the full header matrix.
Private Query Response
Private means the result may depend on the caller. PgPaw requires a bearer token, executes the SQL under the token's Postgres role, and returns rows inline:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
Private query results are not inserted into PgPaw's shared query
cache and do not receive /q/{hash}/{version} cursor
URLs. The no-store directive tells HTTP caches not to
store the response. The private directive marks the
response as user-specific, but no-store is the
stronger protection.
Private live queries follow the same rule. The first SSE event
contains inline rows for that authenticated subscription, and the
stream response uses Cache-Control: no-store.
Configure JWT verification
Configure exactly one key source.
HS256 shared secret:
pgpaw serve \
--jwt-secret "$JWT_SECRET" \
--jwt-role-claim role \
--pg-database myapp
RS256 or ES256 PEM public key:
pgpaw serve \
--jwt-public-key "$JWT_PUBLIC_KEY" \
--jwt-role-claim role \
--pg-database myapp
--jwt-jwks-url is a reserved flag. Current PgPaw
returns a configuration error for JWKS because JWKS verification
is not implemented yet.
Token requirements
The role claim must be a string. The default claim name is
role.
{
"role": "member",
"org_id": 7,
"exp": 1893456000
}
PgPaw validates token expiration. Audience validation is disabled.
How RLS receives claims
For private queries, PgPaw passes the token's Postgres role and
full claims JSON to the local pglite replica. RLS policies can
read the claims through request.jwt.claims.
Example:
create role member;
alter table documents enable row level security;
create policy documents_by_org on documents
for select
to member
using (
org_id = (current_setting('request.jwt.claims', true)::json->>'org_id')::int
);
Request:
curl -i -X POST http://127.0.0.1:8080/query \
-H "content-type: application/json" \
-H "authorization: Bearer $TOKEN" \
-d '{"sql":"select id, title from documents order by id"}'
Private response:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
Securing User Data
Use this checklist for user-specific tables:
- Enable RLS on every table that contains user-specific rows.
-
Revoke
PUBLIC SELECTfrom private tables unless the data is truly public. - Put the caller's Postgres role in the JWT role claim.
-
Put tenant or user identifiers in JWT claims, then read them
from
request.jwt.claimsin RLS policies. - Do not rely on application-side filtering after PgPaw. The SQL result must be safe after Postgres applies role and RLS rules.
-
Do not attach malformed or expired tokens to public requests.
PgPaw validates any
Authorizationheader it receives.
Misconfigured grants are the main danger. If a user-specific table
has RLS disabled and still grants SELECT to
PUBLIC, PgPaw will classify it as public and may
serve it through the shared public snapshot cache.
Errors
| Case | Status |
|---|---|
| Missing token for private query | 401 |
Malformed Authorization header |
401 |
| Token signed with the wrong key | 401 |
| Expired token | 401 |
| Missing role claim | 401 |
| Postgres denies access under the role | 403 |
Database denial SQLSTATEs 42501, 42704,
and 28000 map to 403.
Logs
PgPaw logs auth state without logging tokens:
level=INFO target=pgpaw::auth event=auth_header_present
level=INFO target=pgpaw::auth event=auth_verified role=member
level=WARN target=pgpaw::auth event=auth_failed reason=jwt_verification_not_configured
