Midwess
Learn

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 PUBLIC has SELECT.
  • A table is private when RLS is enabled or PUBLIC lacks SELECT.
  • 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 SELECT from 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.claims in 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 Authorization header 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