Minimal JWT verification for the auth pipeline.
PostgREST authenticates a request by verifying the Authorization: Bearer <token> JWT against the configured jwt-secret, then switching the database
role to the token's role claim (falling back to the anonymous role). This
module performs only the verification half — it never mints tokens (the
conformance cases carry hardcoded tokens) — and maps each failure to the
PostgREST error the cases expect:
- no secret configured but a token is presented ->
:no_secret(PGRST300, 500) - empty bearer token ->
:empty(PGRST301, 401) - not exactly 3 dot-separated parts ->
{:parts, n}(PGRST301, 401) - unreadable token structure ->
:bad_crypto(PGRST301, 401) - unsecured token (
alg: none) ->{:bad_algorithm, d}(PGRST301, 401) - no key verified the signature ->
:jwt_invalid(PGRST301, 401) - payload is not a JSON object ->
:claims_parse_failed(PGRST303) expmore than 30s in the past ->:expired(PGRST303, 401)nbfmore than 30s in the future ->:not_yet_valid(PGRST303, 401)iatmore than 30s in the future ->:issued_at_future(PGRST303, 401)- non-numeric
exp/nbf/iat->{:claim_not_number, claim}(PGRST303) audnot a string / array of strings ->:aud_not_string(PGRST303)- audience mismatch (when
jwt-audconfigured) ->:not_in_audience(PGRST303)
The decode-stage taxonomy
PostgREST hands the token to Haskell jose-jwt's JWT.decode, whose only
three failures it re-labels are KeyError, BadAlgorithm and BadCrypto
(Auth/Jwt.hs jwtDecodeError), each rendered as a distinct PGRST301 body.
The three arise as follows, and this module reproduces the same split:
parseJwt(Jose/Internal/Parser.hs) collapses every structural failure toBadCrypto—first (const BadCrypto) $ parseOnly jwt bs. It base64url-decodes the header, JSON-decodes it into a recognized header (alg: noneis the unsecured header; anything else needs a stringalg), then base64url-decodes the payload and the signature. Any of those failing is:bad_cryptohere,details: nullin the response.- an unsecured header reaches
decode, which — PostgREST passes no expected encoding — throwsBadAlgorithm "JWT is unsecured but expected 'alg' was not 'none'". That jose message is surfaced verbatim as the responsedetails, so it is carried in the error term. - otherwise the key set is filtered by what can verify the header's
algand each candidate is tried; nothing verifying isKeyError. Bier holds a single configured key, so its "wrong secret", "wrong key type" and "algorithm mismatch" outcomes are one bucket (:jwt_invalid), rendered with jose's"None of the keys was able to decode the JWT"details.
Signatures are verified through :jose. The configured secret selects the key:
a JWK (a JSON object with kty, or a JWK Set) verifies asymmetric algorithms
(RS/ES/PS*/EdDSA); any other secret is an HMAC oct key (HS256/384/512).
Routing on the secret — not just the token's alg — keeps a public JWK from
ever being used as an HMAC key (an algorithm-confusion attempt is rejected).
alg: none never reaches that step: an unsecured token is rejected while the
token is still being parsed, so it can never authenticate.
Returns {:ok, %{role: role | nil, claims: map, claims_json: raw_json}} where
claims_json is the exact decoded payload JSON segment (re-encoded canonically)
used to populate request.jwt.claims.
Verification is split into public pieces so Bier.Auth can interpose a
cache: precheck/2 is the nil/empty/no-secret gate, then
decode_and_verify/2 (cacheable: signature + payload decode) and
validate_claims/3 (per-request: temporal + audience checks + role
extraction). Bier.JwtCache caches only the expensive decode_and_verify/2
results; verify/4 recomposes all three for direct (uncached) use.
Summary
Functions
The cacheable half of verification (PostgREST parseAndDecodeClaims):
splits the token, verifies the signature against secret, and decodes the
payload. Returns the claims plus the canonically re-encoded payload JSON
used for request.jwt.claims. Assumes a non-empty token and a present
secret — callers keep the :empty/:no_secret pre-checks. Bier.JwtCache
caches exactly this function's successful result, keyed by the token.
Pre-checks a bearer token ahead of the decode step: nil (no header) ->
{:ok, :anonymous}, a blank token -> {:error, :empty}, no secret
configured -> {:error, :no_secret}, otherwise {:ok, trimmed_token}.
The per-request half (PostgREST validateClaims + role extraction):
temporal (exp/nbf/iat) and audience checks, then the role claim.
Runs on every request — cache hit or not — so a cached token still starts
failing once its exp passes.
Verify the bearer token from the Authorization header.
Functions
@spec decode_and_verify(String.t(), String.t()) :: {:ok, map(), String.t()} | {:error, atom() | {atom(), term()}}
The cacheable half of verification (PostgREST parseAndDecodeClaims):
splits the token, verifies the signature against secret, and decodes the
payload. Returns the claims plus the canonically re-encoded payload JSON
used for request.jwt.claims. Assumes a non-empty token and a present
secret — callers keep the :empty/:no_secret pre-checks. Bier.JwtCache
caches exactly this function's successful result, keyed by the token.
@spec precheck(String.t() | nil, String.t() | nil) :: {:ok, :anonymous} | {:ok, String.t()} | {:error, :empty | :no_secret}
Pre-checks a bearer token ahead of the decode step: nil (no header) ->
{:ok, :anonymous}, a blank token -> {:error, :empty}, no secret
configured -> {:error, :no_secret}, otherwise {:ok, trimmed_token}.
Shared by verify/4 and Bier.Auth, which interposes Bier.JwtCache
between this check and decode_and_verify/2 rather than calling
verify_token/4 directly — keeping the check in one place means the two
callers can't drift apart on it.
@spec validate_claims(map(), String.t() | nil, Bier.JWT.RoleClaim.path()) :: {:ok, String.t() | nil} | {:error, atom() | {atom(), term()}}
The per-request half (PostgREST validateClaims + role extraction):
temporal (exp/nbf/iat) and audience checks, then the role claim.
Runs on every request — cache hit or not — so a cached token still starts
failing once its exp passes.
@spec verify( String.t() | nil, String.t() | nil, String.t() | nil, Bier.JWT.RoleClaim.path() ) :: {:ok, :anonymous} | {:ok, %{role: String.t() | nil, claims: map(), claims_json: String.t()}} | {:error, atom() | {atom(), term()}}
Verify the bearer token from the Authorization header.
niltoken (no header) ->{:ok, :anonymous}- a present token, no secret ->
{:error, :no_secret} - a present, valid token ->
{:ok, %{role:, claims:, claims_json:}} - a present, invalid token ->
{:error, reason}
role_claim_path is the parsed jwt-role-claim-key JSON Path
(Bier.JWT.RoleClaim) locating the role inside the claims; it defaults to
PostgREST's $.role.