Authentication
Secure your API with JWT authentication and PostgreSQL Row-Level Security.
JWT Authentication
Postrust validates JWT tokens and extracts role claims to determine database access.
{
"role": "authenticated_user",
"sub": "user_123",
"email": "user@example.com",
"exp": 1704067200
}curl http://localhost:3000/users \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."How the claims are checked
A claim that is absent is not a claim that is wrong: a token carrying no exp does not expire, and one carrying no aud is for anybody. Only a claim that is present and is not the kind of thing that claim is gets rejected.
- expChecked to the second. Forgiving an expiry keeps a session alive past the moment its issuer said it ended, so no slack is given here.
- nbf, iatThirty seconds of clock skew allowed. These describe a token that is not valid yet, and a client whose clock runs slightly fast mints one through nobody's fault; refusing it makes a working deployment fail intermittently.
- roleMust be a string. A role claim of any other shape, or
""ornone, names no role and falls back to the anonymous role, rather than asking PostgreSQL for a role of that spelling. - issNot looked at unless
PGRST_JWT_ISSis set. Then it must be present and equal it exactly: where an identity provider’s keys are shared across tenants, the issuer is what tells one tenant’s tokens from another’s.
A 401 names which refusal in its WWW-Authenticate challenge, so a client can tell “rotate your key” from “fix your clock” without reading the body.
Signing keys
PGRST_JWT_SECRET is read as PostgREST reads jwt-secret. A plain string is an HMAC secret and admits HS256 tokens. A JSON Web Key, or a JSON Web Key Set, is a set of public keys: how an identity provider’s keys are given.
# A shared HMAC secret (HS256)
PGRST_JWT_SECRET="your-secret-key-at-least-32-characters"
# A JWKS, as an identity provider publishes it
PGRST_JWT_SECRET='{"keys":[{"kid":"2024-07","kty":"RSA","alg":"RS256","n":"0vx7...","e":"AQAB"}]}'| kty | Algorithms |
|---|---|
| RSA | RS256/384/512, PS256/384/512 (modulus of at least 2048 bits) |
| EC | ES256 (P-256), ES384 (P-384) |
| OKP | EdDSA (Ed25519) |
| oct | HS256/384/512 |
The algorithm comes from the key, never from the token, so neitheralg: none nor an HS256 token “signed” with an RSA public key gets through. A token with a kid is checked against the key with that kid only; one without is tried against every key that admits its algorithm. Keys that cannot verify (encryption keys, unsupported curves) are skipped with a warning; a malformed key stops the server from starting.
Keys from an identity provider
Give PGRST_JWT_JWKS_URL the provider’s jwks_uri and Postrust fetches the key set from there and keeps it current, so a key the provider rotates in is picked up without a restart and a key it withdraws stops being accepted.
# Supabase Auth, on asymmetric signing keys
PGRST_JWT_JWKS_URL="https://<project>.supabase.co/auth/v1/.well-known/jwks.json"
# Auth0
PGRST_JWT_JWKS_URL="https://<tenant>.auth0.com/.well-known/jwks.json"
# Keycloak
PGRST_JWT_JWKS_URL="https://<host>/realms/<realm>/protocol/openid-connect/certs"
# Optional: only accept tokens this issuer minted
PGRST_JWT_ISS="https://<tenant>.auth0.com/"- Fetched at start-up, then again after the response’s
Cache-Control: max-age(held to between 60 seconds and a day), or everyPGRST_JWT_JWKS_REFRESHseconds (default 300). - A token naming a
kidthe set does not hold triggers one more fetch first — a key just rotated in — at most once every 30 seconds. httpsonly (httpjust for loopback), redirects only to the same host, 5 seconds and 1 MiB per fetch.- Every change of the set empties the token cache, so a withdrawn key’s tokens are refused from the next request on.
- If the provider is down, the last set that loaded is kept. If none ever loaded, tokens are refused (fail closed) and
/_/readyanswers503with"jwks": false. - With
PGRST_JWT_SECRETset as well, a token either one verifies is accepted: a backend’s service tokens beside the provider’s user tokens.
Configuration
PGRST_JWT_SECRETAn HMAC secret (HS256), or a JWK or JWKS of public keys (RS, PS, ES, EdDSA)
PGRST_JWT_SECRET_IS_BASE64Set true if secret is base64 encoded
PGRST_JWT_JWKS_URLFetch the keys from an identity provider's JWKS URL and keep them current
PGRST_JWT_JWKS_REFRESHSeconds between fetches when the response has no max-age (default: 300)
PGRST_JWT_AUDRequired audience claim (optional)
PGRST_JWT_ISSRequired issuer: a token's iss must equal it, and must be present (optional)
PGRST_JWT_ROLE_CLAIM_KEYClaim key containing role (default: role)
Row-Level Security
PostgreSQL RLS policies are enforced on every request. JWT claims are available as session variables.
-- Enable RLS on table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their own orders
CREATE POLICY user_orders ON orders
FOR ALL
USING (
user_id = current_setting('request.jwt.claims')::json->>'sub'
);
-- Policy: admins can see all orders
CREATE POLICY admin_orders ON orders
FOR ALL
USING (
current_setting('request.jwt.claims')::json->>'role' = 'admin'
);Accessing JWT Claims in SQL
-- Get full claims object
current_setting('request.jwt.claims')::json
-- Get specific claim
current_setting('request.jwt.claims')::json->>'sub'
current_setting('request.jwt.claims')::json->>'email'
current_setting('request.jwt.claims')::json->>'role'
-- Use in function
CREATE FUNCTION get_current_user_id() RETURNS TEXT AS $$
SELECT current_setting('request.jwt.claims')::json->>'sub';
$$ LANGUAGE SQL STABLE;The GraphQL surface
Everything above is how the REST surface authenticates. The GraphQL surface at /v1/graphql reads Hasura’s contract instead — an admin secret, x-hasura-* session variables, and a role that decides which schema answers before it decides anything about a row.
The two meet at the token: a verified JWT names the role either way, and on the GraphQL side an X-Hasura-Role header may select any role the token’s x-hasura-allowed-roles lists. See Configuration for the settings and GraphQL for the surface itself.
Either way the verified claims reach the database, so one RLS policy serves both: request.jwt.claims is set in every GraphQL transaction as it is in every REST one. That includes subscriptions. A WebSocket is authenticated once, from the connection_init payload — {"headers": {"Authorization": "Bearer <token>"}}, since a browser cannot set headers on a WebSocket — and every live query on it re-reads as that identity.