Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Drop support for PostgreSQL EOL version 12 by @wolfgangwalther in #3865
- Replaced `jwt-cache-max-lifetime` config with `jwt-cache-max-entries` by @mkleczek in #4084
- `log-query` config now takes a boolean instead of a string value by @steve-chavez in #3934
- `jwt-aud` config now takes a regular expression to match against `aud` claim #2099

## [13.0.7] - 2025-09-14

Expand Down
6 changes: 5 additions & 1 deletion docs/references/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,17 @@ PostgREST has built-in validation of the `JWT audience claim <https://datatracke
It works this way:

- If :ref:`jwt-aud` is not set (the default), PostgREST identifies with all audiences and allows the JWT for any ``aud`` claim.
- If :ref:`jwt-aud` is set to a specific audience, PostgREST will check if this audience is present in the ``aud`` claim:
- If :ref:`jwt-aud` is set, PostgREST will treat it as a regular expression and check if it matches the ``aud`` claim:

+ If the ``aud`` value is a JSON string, it will match it to the :ref:`jwt-aud`.
+ If the ``aud`` value is a JSON array of strings, it will search every element for a match.
+ If the match fails or if the ``aud`` value is not a string or array of strings, then the token will be rejected with a :ref:`401 Unauthorized <pgrst303>` error.
+ If the ``aud`` key **is not present** or if its value is ``null`` or ``[]``, PostgREST will interpret this token as allowed for all audiences and will complete the request.

Examples:
- To make PostgREST accept ``aud`` claim value from a set ``audience1``, ``audience2``, ``otheraudience``, :ref:`jwt-aud` claim should be set to ``audience1|audience2|otheraudience``.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to wrap my head around this.

By the docs, right now we support:

If the aud value is a JSON array of strings, it will search every element for a match.

But that's just the JWT and not the jwt-aud config.

So with this change now jwt-aud can have a list of audiences (using or expression), and the JWT can too specify a list of audiences (using JSON array).

Copy link
Contributor Author

@mkleczek mkleczek Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to wrap my head around this.

By the docs, right now we support:

If the aud value is a JSON array of strings, it will search every element for a match.

But that's just the JWT and not the jwt-aud config.

Yes.
(I think this sentence was not changed)

So with this change now jwt-aud can have a list of audiences (using or expression), and the JWT can too specify a list of audiences (using JSON array).

Yes.
That's what #2099 is about.

- To make PostgREST accept ``aud`` claim value matching any ``https`` URI pointing to a host in ``example.com`` domain, :ref:`jwt-aud` claim should be set to ``https://[a-zA-Z0-9_]*\.example\.com``.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ReDoS a possibility?

What would be the performance impact of this new feature? Does the JWT cache help here?

Copy link
Contributor Author

@mkleczek mkleczek Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ReDoS a possibility?

We validate aud only after JWT authentication (ie. we verify JWT signature first). So it is only possible if an attacker can issue tokens.
Short answer: IMHO no

What would be the performance impact of this new feature? Does the JWT cache help here?

Performance impact needs to be verified.

Right now we do not cache claims validation results so JWT won't help.
We can change it but that will require reloading JWT cache not only whenjwt-secret changes but also when jwt-aud is modified. I would postpone this until we are sure regex matching really affects performance.

There is also another potential performance related issue:

60c8a98 introduces syntactic validation of aud claim. Before, as implemented by @taimoorzaeem in #4140, we didn't really check if aud claim is a valid StringOrURI - we only verified that jwt-aud config is syntactically valid. So:

  • we did not validate aud claims syntactically when jwt-aud was not set
  • we returned wrong error message when jwt-aud was set and aud claim was invalid URI: instead of "aud syntax error" we returned "JWT not in audience" (that's disputable as both are valid rejection reasons)
  • in case of jwt-aud config being a regular expression we cannot really check if it is a valid StringOrURI anymore (the main reason to implement it in this PR)

Checking aud claim syntactically is additional work to perform upon every request so will affect performance (we don't know if noticeably).

I've implemented it in a separate commit so that we can easily get rid of it (as nothing depends on it). Not sure if syntactical check of aud claim is that important anyway. OTOH caching aud claims validation in JWT cache would help with this case as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also another potential performance related issue:
60c8a98 introduces syntactic validation of aud claim.
(Note: the above was released on v13.0.4)

@mkleczek Q: The performance hit would only happen if jwt-aud is set right? And with this new PR, jwt-aud will always be set hence the perf impact will happen for every installation (the jwt-aud='.*' regex check will always be done).

Performance impact needs to be verified.
Checking aud claim syntactically is additional work to perform upon every request so will affect performance (we don't know if noticeably).

So to check the above we would need new loadtests with the jwt-aud enabled right?

Copy link
Contributor Author

@mkleczek mkleczek Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also another potential performance related issue:
60c8a98 introduces syntactic validation of aud claim.
(Note: the above was released on v13.0.4)

@mkleczek Q: The performance hit would only happen if jwt-aud is set right? And with this new PR, jwt-aud will always be set hence the perf impact will happen for every installation (the jwt-aud='.*' regex check will always be done).

No. Currently (ie. in main) we don't check aud claim syntactically at all. We only syntactically check jwt-aud config. But we use equality check to validate aud claim hence we don't accept syntactically invalid auds if jwt-aud is set but accept invalid auds if jwt-aud is not set.

This change was supposed to change that and validate auds syntactically always, before even checking them against jwt-aud config.

Nevertheless, I removed commit introducing this check for now.

Performance impact needs to be verified.
Checking aud claim syntactically is additional work to perform upon every request so will affect performance (we don't know if noticeably).

So to check the above we would need new loadtests with the jwt-aud enabled right?

Nope, see above.


.. _jwt_caching:

JWT Cache
Expand Down
4 changes: 2 additions & 2 deletions docs/references/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -596,14 +596,14 @@ jwt-aud
-------

=============== =================================
**Type** String
**Type** String (must be a valid regular expression)
**Default** `n/a`
Copy link
Member

@laurenceisla laurenceisla Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
**Default** `n/a`
**Default** .*

Perhaps also mention in the description something like: "For example, the default value .* matches any aud claim."?

**Reloadable** Y
**Environment** PGRST_JWT_AUD
**In-Database** pgrst.jwt_aud
=============== =================================

Specifies an audience for the JWT ``aud`` claim. See :ref:`jwt_aud`.
Specifies a regular expression to match against the JWT ``aud`` claim. See :ref:`jwt_aud`.

.. _jwt-role-claim-key:

Expand Down
48 changes: 28 additions & 20 deletions src/PostgREST/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module PostgREST.Config
, LogLevel(..)
, OpenAPIMode(..)
, Proxy(..)
, CfgAud
, toText
, isMalformedProxyUri
, readAppConfig
Expand All @@ -29,6 +30,8 @@ module PostgREST.Config
, addTargetSessionAttrs
, exampleConfigFile
, audMatchesCfg
, defaultCfgAud
, parseCfgAud
) where

import qualified Data.Aeson as JSON
Expand All @@ -50,7 +53,7 @@ import Data.List.NonEmpty (fromList, toList)
import Data.Maybe (fromJust)
import Data.Scientific (floatingOrInteger)
import Jose.Jwk (Jwk, JwkSet)
import Network.URI (escapeURIString, isURI,
import Network.URI (escapeURIString,
isUnescapedInURIComponent)
import Numeric (readOct, showOct)
import System.Environment (getEnvironment)
Expand All @@ -66,10 +69,29 @@ import PostgREST.Config.Proxy (Proxy (..),
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
toQi)

import Protolude hiding (Proxy, toList)
import Protolude hiding (Proxy, toList)
import qualified Text.Regex.TDFA as R

data ParsedValue a b = ParsedValue {
sourceValue :: a,
parsedValue :: b
}

newtype CfgAud = CfgAud { unCfgAud :: ParsedValue (Maybe Text) R.Regex }

parseCfgAud :: MonadFail m => Text -> m CfgAud
parseCfgAud = fmap CfgAud . (fmap . ParsedValue . Just <*> parseRegex)
where
parseRegex = maybe (fail "jwt-aud should be a valid regular expression") pure . R.makeRegexM . bounded
-- need start and end of text bounds so that
-- regex does not match parts of text
bounded = ("\\`(" <>) . (<> "\\')")

defaultCfgAud :: CfgAud
defaultCfgAud = CfgAud $ ParsedValue Nothing $ R.makeRegex (".*"::Text)

audMatchesCfg :: AppConfig -> Text -> Bool
audMatchesCfg = maybe (const True) (==) . configJwtAudience
audMatchesCfg = R.matchTest . parsedValue . unCfgAud . configJwtAudience

data AppConfig = AppConfig
{ configAppSettings :: [(Text, Text)]
Expand Down Expand Up @@ -97,7 +119,7 @@ data AppConfig = AppConfig
, configDbUri :: Text
, configFilePath :: Maybe FilePath
, configJWKS :: Maybe JwkSet
, configJwtAudience :: Maybe Text
, configJwtAudience :: CfgAud
, configJwtRoleClaimKey :: JSPath
, configJwtSecret :: Maybe BS.ByteString
, configJwtSecretIsBase64 :: Bool
Expand Down Expand Up @@ -171,7 +193,7 @@ toText conf =
,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig)
,("db-tx-end", q . showTxEnd)
,("db-uri", q . configDbUri)
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
,("jwt-aud", q . fold . sourceValue . unCfgAud . configJwtAudience)
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
Expand Down Expand Up @@ -279,7 +301,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> (fromMaybe "postgresql://" <$> optString "db-uri")
<*> pure optPath
<*> pure Nothing
<*> optStringOrURI "jwt-aud"
<*> (optStringEmptyable "jwt-aud" >>= maybe (pure defaultCfgAud) parseCfgAud)
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
<*> (fromMaybe False <$> optWithAlias
Expand Down Expand Up @@ -399,20 +421,6 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text)
optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText

optStringOrURI :: C.Key -> C.Parser C.Config (Maybe Text)
optStringOrURI k = do
stringOrURI <- mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText
-- If the string contains ':' then it should
-- be a valid URI according to RFC 3986
case stringOrURI of
Just s -> if T.isInfixOf ":" s then validateURI s else return (Just s)
Nothing -> return Nothing
where
validateURI :: Text -> C.Parser C.Config (Maybe Text)
validateURI s = if isURI (T.unpack s)
then return $ Just s
else fail "jwt-aud should be a string or a valid URI"

optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt

Expand Down
2 changes: 1 addition & 1 deletion test/io/fixtures.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ cli:
expect: error
use_defaultenv: true
env:
PGRST_JWT_AUD: 'http://%%localhorst.invalid'
PGRST_JWT_AUD: '['
- name: invalid log-level
expect: error
use_defaultenv: true
Expand Down
8 changes: 4 additions & 4 deletions test/io/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,15 +266,15 @@ def test_schema_cache_snapshot(baseenv, key, snapshot_yaml):
assert formatted == snapshot_yaml


def test_jwt_aud_config_set_to_invalid_uri(defaultenv):
"PostgREST should exit with an error message in output if jwt-aud config is set to an invalid URI"
def test_jwt_aud_config_set_to_invalid_regex(defaultenv):
"PostgREST should exit with an error message in output if jwt-aud config is set to an invalid regular expression"
env = {
**defaultenv,
"PGRST_JWT_AUD": "foo://%%$$^^.com",
"PGRST_JWT_AUD": "[",
}

error = cli(["--dump-config"], env=env, expect_error=True)
assert "jwt-aud should be a string or a valid URI" in error
assert "jwt-aud should be a valid regular expression" in error


def test_jwt_secret_min_length(defaultenv):
Expand Down
7 changes: 5 additions & 2 deletions test/spec/SpecHelper.hs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ import PostgREST.Config (AppConfig (..),
JSPathExp (..),
LogLevel (..),
OpenAPIMode (..),
defaultCfgAud, parseCfgAud,
parseSecret)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import Protolude hiding (get, toS)
import Protolude.Conv (toS)
import Protolude.Partial (fromJust)

filterAndMatchCT :: BS.ByteString -> MatchHeader
filterAndMatchCT val = MatchHeader $ \headers _ ->
Expand Down Expand Up @@ -135,7 +137,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in
, configDbUri = "postgresql://"
, configFilePath = Nothing
, configJWKS = rightToMaybe $ parseSecret secret
, configJwtAudience = Nothing
, configJwtAudience = defaultCfgAud
, configJwtRoleClaimKey = [JSPKey "role"]
, configJwtSecret = Just secret
, configJwtSecretIsBase64 = False
Expand Down Expand Up @@ -218,7 +220,8 @@ testCfgAudienceJWT :: AppConfig
testCfgAudienceJWT =
baseCfg {
configJwtSecret = Just generateSecret
, configJwtAudience = Just "youraudience"
-- parseCfgAud might fail on invalid regex but it is safe here
, configJwtAudience = fromJust $ parseCfgAud "urn..uriaudience|youraudience"
, configJWKS = rightToMaybe $ parseSecret generateSecret
}

Expand Down