Repository: avenga/couper
Pull request #502 (2022-05-09 07:47:44)

Jwks max stale
* `jwks_max_stale` in `jwt` block
* `jwks_ttl`, `jwks_max_stale` and `configuration_max_stale` in `oidc` block


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 857608e7..adad0df3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ Unreleased changes are available as `avenga/couper:edge` container.
     * `backends.<name>.health` variable to access the current health-check state _(subject to change)_
   * Log malformed duration settings ([#487](https://github.com/avenga/couper/pull/487))
   * `url` attribute could make use of our wildcard pattern `/**` and relative urls in combination with a backend reference ([#480](https://github.com/avenga/couper/pull/480))
+  * `jwks_max_stale` in [`jwt` block](./docs/REFERENCE.md#jwt-block) ([#502](https://github.com/avenga/couper/pull/502))
+  * `jwks_ttl`, `jwks_max_stale` and `configuration_max_stale` in [`oidc` block](./docs/REFERENCE.md#oidc-block) ([#502](https://github.com/avenga/couper/pull/502))
   * Error handling for `backend`, `backend_openapi_validation` and `backend_timeout` [error types](./docs/ERRORS.md) ([#490](https://github.com/avenga/couper/pull/490))
   * `response.bytes` log-field to backend logs if read from body, fallback is the `Content-Length` header ([#494](https://github.com/avenga/couper/pull/494))
   * [Error types](./docs/ERRORS.md) `endpoint` and `access_control` ([#500](https://github.com/avenga/couper/pull/500))
diff --git a/accesscontrol/jwk/jwks.go b/accesscontrol/jwk/jwks.go
index 6c07ce19..6b0fd1e0 100644
--- a/accesscontrol/jwk/jwks.go
+++ b/accesscontrol/jwk/jwks.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/dgrijalva/jwt-go/v4"
 
+	"github.com/avenga/couper/config"
 	jsn "github.com/avenga/couper/json"
 )
 
@@ -29,12 +30,12 @@ type JWKS struct {
 	syncedJSON *jsn.SyncedJSON
 }
 
-func NewJWKS(uri string, ttl string, transport http.RoundTripper) (*JWKS, error) {
-	if ttl == "" {
-		ttl = "1h"
+func NewJWKS(uri string, ttl string, maxStale string, transport http.RoundTripper) (*JWKS, error) {
+	timetolive, err := config.ParseDuration("jwks_ttl", ttl, time.Hour)
+	if err != nil {
+		return nil, err
 	}
-
-	timetolive, err := time.ParseDuration(ttl)
+	maxStaleTime, err := config.ParseDuration("jwks_max_stale", maxStale, time.Hour)
 	if err != nil {
 		return nil, err
 	}
@@ -46,7 +47,7 @@ func NewJWKS(uri string, ttl string, transport http.RoundTripper) (*JWKS, error)
 	}
 
 	jwks := &JWKS{}
-	jwks.syncedJSON, err = jsn.NewSyncedJSON(file, "jwks_url", uri, transport, "jwks", timetolive, jwks)
+	jwks.syncedJSON, err = jsn.NewSyncedJSON(file, "jwks_url", uri, transport, "jwks", timetolive, maxStaleTime, jwks)
 	return jwks, err
 }
 
@@ -114,13 +115,10 @@ func (j *JWKS) getKeys(kid string) ([]*JWK, error) {
 
 func (j *JWKS) Data() (*JWKSData, error) {
 	data, err := j.syncedJSON.Data()
-	if err != nil {
-		return nil, err
-	}
-
+	// Ignore backend errors as long as we still get cached (stale) data.
 	jwksData, ok := data.(*JWKSData)
 	if !ok {
-		return nil, fmt.Errorf("data not JWKS data: %#v", data)
+		return nil, fmt.Errorf("received no valid JWKs data: %#v, %w", data, err)
 	}
 
 	return jwksData, nil
diff --git a/config/ac_jwt.go b/config/ac_jwt.go
index 16ae7fca..e93c8508 100644
--- a/config/ac_jwt.go
+++ b/config/ac_jwt.go
@@ -25,6 +25,7 @@ type JWT struct {
 	Header                string              `hcl:"header,optional"`
 	JWKsURL               string              `hcl:"jwks_url,optional"`
 	JWKsTTL               string              `hcl:"jwks_ttl,optional"`
+	JWKsMaxStale          string              `hcl:"jwks_max_stale,optional"`
 	Key                   string              `hcl:"key,optional"`
 	KeyFile               string              `hcl:"key_file,optional"`
 	Name                  string              `hcl:"name,label"`
diff --git a/config/ac_oidc.go b/config/ac_oidc.go
index ab74d01d..63740747 100644
--- a/config/ac_oidc.go
+++ b/config/ac_oidc.go
@@ -22,11 +22,14 @@ type OIDC struct {
 	ClientID                string   `hcl:"client_id"`
 	ClientSecret            string   `hcl:"client_secret"`
 	ConfigurationURL        string   `hcl:"configuration_url"`
+	JWKsTTL                 string   `hcl:"jwks_ttl,optional"`
+	JWKsMaxStale            string   `hcl:"jwks_max_stale,optional"`
 	Name                    string   `hcl:"name,label"`
 	Remain                  hcl.Body `hcl:",remain"`
 	Scope                   *string  `hcl:"scope,optional"`
 	TokenEndpointAuthMethod *string  `hcl:"token_endpoint_auth_method,optional"`
 	ConfigurationTTL        string   `hcl:"configuration_ttl,optional"`
+	ConfigurationMaxStale   string   `hcl:"configuration_max_stale,optional"`
 	VerifierMethod          string   `hcl:"verifier_method,optional"`
 
 	// configuration related backends
diff --git a/config/duration.go b/config/duration.go
new file mode 100644
index 00000000..b2a3ff21
--- /dev/null
+++ b/config/duration.go
@@ -0,0 +1,22 @@
+package config
+
+import (
+	"fmt"
+	"time"
+)
+
+func ParseDuration(attribute string, value string, _default time.Duration) (time.Duration, error) {
+	if value == "" {
+		return _default, nil
+	}
+
+	duration, err := time.ParseDuration(value)
+	if err != nil {
+		return 0, fmt.Errorf("%s: %s", attribute, err)
+	}
+	if duration < 0 {
+		return 0, fmt.Errorf("%s: cannot be negative: %q", attribute, value)
+	}
+
+	return duration, nil
+}
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 38313bd1..f7b34344 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -610,7 +610,7 @@ func configureJWKS(jwtConf *config.JWT, confContext *hcl.EvalContext, log *logru
 		}
 	}
 
-	return jwk.NewJWKS(jwtConf.JWKsURL, jwtConf.JWKsTTL, backend)
+	return jwk.NewJWKS(jwtConf.JWKsURL, jwtConf.JWKsTTL, jwtConf.JWKsMaxStale, backend)
 }
 
 type protectedOptions struct {
diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md
index f24a7451..903c296f 100644
--- a/docs/REFERENCE.md
+++ b/docs/REFERENCE.md
@@ -396,6 +396,7 @@ Since responses from endpoints protected by JWT access controls are not publicly
 | `beta_roles_map` |object (string)|-| mapping of roles to granted permissions | Non-mapped roles can be assigned with `*` to specific permissions. |`beta_roles_map = { role1 = ["p1", "p2"], role2 = ["p3"], "*" = ["public"] }`|
 | `jwks_url` | string | - | URI pointing to a set of [JSON Web Keys (RFC 7517)](https://datatracker.ietf.org/doc/html/rfc7517) | - | `jwks_url = "http://identityprovider:8080/jwks.json"` |
 | `jwks_ttl` | [duration](#duration) | `"1h"` | Time period the JWK set stays valid and may be cached. | - | `jwks_ttl = "1800s"` |
+| `jwks_max_stale`          | [duration](#duration) | `"1h"` | Time period the cached JWK set stays valid after its TTL has passed. | - | `jwks_max_stale = "45m"` |
 | `backend`  | string| - | [backend reference](#backend-block) for enhancing JWKS requests| - | `backend = "jwks_backend"` |
 | `disable_private_caching` | bool | `false` | If set to `true`, Couper does not add the `private` directive to the `Cache-Control` HTTP header field value. | - | - |
 
@@ -487,8 +488,9 @@ Like all [Access Control](#access-control) types, the `oidc` block is defined in
 |:-----------------------------|:----------------------|:----------------------|:-------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------|
 | `backend`                    | string                | -                     | [Backend Block Reference](#backend-block)                                      | &#9888; Do not disable the peer certificate validation with `disable_certificate_validation = true`!                                                                                                                              | -                                           |
 | `configuration_url`          | string                | -                     | The OpenID configuration URL.                                                  | &#9888; required                                                                                                                                                                                                                  | -                                           |
-| `configuration_ttl`          | [duration](#duration) | `1h`                  | The duration to cache the OpenID configuration located at `configuration_url`. | -                                                                                                                                                                                                                                 | `configuration_ttl = "1d"`                  |
-| `token_endpoint_auth_method` | string                | `client_secret_basic` | Defines the method to authenticate the client at the token endpoint.           | If set to `client_secret_post`, the client credentials are transported in the request body. If set to `client_secret_basic`, the client credentials are transported via Basic Authentication.                                     | -                                           |
+| `configuration_ttl`          | [duration](#duration) | `"1h"`                | The duration to cache the OpenID configuration located at `configuration_url`. | -                                                                                                                                                                                                                                 | `configuration_ttl = "1d"`                  |
+| `configuration_max_stale`    | [duration](#duration) | `"1h"`                | Duration a cached OpenID configuration stays valid after its TTL has passed.   | -                                                                                                                                                                                                                                 | `configuration_max_stale = "2h"`            |
+| `token_endpoint_auth_method` | string                | `"client_secret_basic"` | Defines the method to authenticate the client at the token endpoint.         | If set to `client_secret_post`, the client credentials are transported in the request body. If set to `client_secret_basic`, the client credentials are transported via Basic Authentication.                                     | -                                           |
 | `redirect_uri`               | string                | -                     | The Couper endpoint for receiving the authorization code.                      | &#9888; required. Relative URL references are resolved against the origin of the current request URL. The origin can be changed with the [`accept_forwarded_url`](#settings-block) attribute if Couper is running behind a proxy. | -                                           |
 | `client_id`                  | string                | -                     | The client identifier.                                                         | &#9888; required                                                                                                                                                                                                                  | -                                           |
 | `client_secret`              | string                | -                     | The client password.                                                           | &#9888; required.                                                                                                                                                                                                                 | -                                           |
@@ -496,11 +498,14 @@ Like all [Access Control](#access-control) types, the `oidc` block is defined in
 | `verifier_method`            | string                | -                     | The method to verify the integrity of the authorization code flow              | available values: `ccm_s256` (`code_challenge` parameter with `code_challenge_method` `S256`), `nonce` (`nonce` parameter)                                                                                                        | `verifier_method = "nonce"`                 |
 | `verifier_value`             | string or expression  | -                     | The value of the (unhashed) verifier.                                          | &#9888; required; e.g. using cookie value created with [`oauth2_verifier()` function](#functions)                                                                                                                                 | `verifier_value = request.cookies.verifier` |
 | `custom_log_fields`          | object                | -                     | Defines log fields for [Custom Logging](LOGS.md#custom-logging).               | &#9888; Inherited by nested blocks.                                                                                                                                                                                               | -                                           |
+| `jwks_ttl`                   | [duration](#duration) | `"1h"`                | Time period the JWK set stays valid and may be cached.                         | -                                                                                                                                                                                                                                 | `jwks_ttl = "3h"`                           |
+| `jwks_max_stale`             | [duration](#duration) | `"1h"`                | Time period the cached JWK set stays valid after its TTL has passed.           | -                                                                                                                                                                                                                                 | `jwks_max_stale = "1h30m"`                  |
 | `configuration_backend`      | string                | -                     | [Backend Block Reference](#backend-block)                                      | &#9888; Do not disable the peer certificate validation with `disable_certificate_validation = true`!                                                                                                                              | -                                           |
 | `jwks_uri_backend`           | string                | -                     | [Backend Block Reference](#backend-block)                                      | &#9888; Do not disable the peer certificate validation with `disable_certificate_validation = true`!                                                                                                                              | -                                           |
 | `token_backend`              | string                | -                     | [Backend Block Reference](#backend-block)                                      | &#9888; Do not disable the peer certificate validation with `disable_certificate_validation = true`!                                                                                                                              | -                                           |
 | `userinfo_backend`           | string                | -                     | [Backend Block Reference](#backend-block)                                      | &#9888; Do not disable the peer certificate validation with `disable_certificate_validation = true`!                                                                                                                              | -                                           |
 
+
 In most cases, referencing one `backend` (backend attribute) for all the backend requests sent by the OIDC client is enough.
 You should only use `configuration_backend`, `jwks_uri_backend`, `token_backend` or `userinfo_backend` if you need to configure a specific behaviour for the respective request (e.g. timeouts).
 
diff --git a/handler/transport/transport.go b/handler/transport/transport.go
index cb6d4fb6..3201c03f 100644
--- a/handler/transport/transport.go
+++ b/handler/transport/transport.go
@@ -14,6 +14,7 @@ import (
 	"go.opentelemetry.io/otel/attribute"
 	"go.opentelemetry.io/otel/trace"
 
+	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/request"
 	"github.com/avenga/couper/telemetry"
 	"golang.org/x/net/http/httpproxy"
@@ -154,15 +155,15 @@ func (c *Config) WithTarget(scheme, origin, hostname, proxyURL string) *Config {
 
 func (c *Config) WithTimings(connect, ttfb, timeout string, logger *logrus.Entry) *Config {
 	conf := *c
-	parseDuration(connect, &conf.ConnectTimeout, logger)
-	parseDuration(ttfb, &conf.TTFBTimeout, logger)
-	parseDuration(timeout, &conf.Timeout, logger)
+	parseDuration(connect, &conf.ConnectTimeout, "connect_timeout", logger)
+	parseDuration(ttfb, &conf.TTFBTimeout, "ttfb_timeout", logger)
+	parseDuration(timeout, &conf.Timeout, "timeout", logger)
 	return &conf
 }
 
 // parseDuration sets the target value if the given duration string is not empty.
-func parseDuration(src string, target *time.Duration, logger *logrus.Entry) {
-	d, err := time.ParseDuration(src)
+func parseDuration(src string, target *time.Duration, attributeName string, logger *logrus.Entry) {
+	d, err := config.ParseDuration(attributeName, src, *target)
 	if err != nil {
 		logger.WithError(err).Warning("using default timing of ", target, " because an error occured")
 	}
diff --git a/json/synced_json.go b/json/synced_json.go
index 6f5bd653..9fc4b405 100644
--- a/json/synced_json.go
+++ b/json/synced_json.go
@@ -21,6 +21,7 @@ type dataRequest struct {
 }
 
 type SyncedJSON struct {
+	maxStale      time.Duration
 	roundTripName string
 	transport     http.RoundTripper
 	ttl           time.Duration
@@ -32,9 +33,10 @@ type SyncedJSON struct {
 	fileMode    bool
 }
 
-func NewSyncedJSON(file, fileContext, uri string, transport http.RoundTripper, roundTripName string, ttl time.Duration, unmarshaller SyncedJSONUnmarshaller) (*SyncedJSON, error) {
+func NewSyncedJSON(file, fileContext, uri string, transport http.RoundTripper, roundTripName string, ttl time.Duration, maxStale time.Duration, unmarshaller SyncedJSONUnmarshaller) (*SyncedJSON, error) {
 	sj := &SyncedJSON{
 		dataRequest:   make(chan chan *dataRequest, 10),
+		maxStale:      maxStale,
 		roundTripName: roundTripName,
 		transport:     transport,
 		ttl:           ttl,
@@ -58,14 +60,21 @@ func NewSyncedJSON(file, fileContext, uri string, transport http.RoundTripper, r
 
 func (s *SyncedJSON) sync(ctx context.Context) {
 	var expired <-chan time.Time
+	var invalidated <-chan time.Time
+	var backoff time.Duration
+
+	init := func() {
+		expired = time.After(s.ttl)
+		backoff = time.Second
+	}
+
+	init()
+
 	err := s.fetch() // initial fetch, provide any startup errors for first dataRequest's
 	if err != nil {
 		expired = time.After(0)
-	} else {
-		expired = time.After(s.ttl)
 	}
 
-	backoff := time.Second
 	for {
 		select {
 		case <-ctx.Done():
@@ -73,19 +82,21 @@ func (s *SyncedJSON) sync(ctx context.Context) {
 		case <-expired:
 			err = s.fetch()
 			if err != nil {
-				time.Sleep(backoff)
+				invalidated = time.After(s.maxStale)
+				expired = time.After(backoff)
 				if backoff < time.Minute {
 					backoff *= 2
 				}
 				continue
 			}
-			expired = time.After(s.ttl)
-			backoff = time.Second
+			init()
 		case r := <-s.dataRequest:
 			r <- &dataRequest{
 				err: err,
 				obj: s.data,
 			}
+		case <-invalidated:
+			s.data = nil
 		}
 	}
 }
diff --git a/oauth2/oidc/config.go b/oauth2/oidc/config.go
index 88a997b1..9cbf29e8 100644
--- a/oauth2/oidc/config.go
+++ b/oauth2/oidc/config.go
@@ -48,13 +48,13 @@ type Config struct {
 
 // NewConfig creates a new configuration for an OIDC client
 func NewConfig(oidc *config.OIDC, backends map[string]http.RoundTripper) (*Config, error) {
-	ttl := defaultTTL
-	if oidc.ConfigurationTTL != "" {
-		t, err := time.ParseDuration(oidc.ConfigurationTTL)
-		if err != nil {
-			return nil, err
-		}
-		ttl = t
+	ttl, err := config.ParseDuration("configuration_ttl", oidc.ConfigurationTTL, defaultTTL)
+	if err != nil {
+		return nil, err
+	}
+	maxStale, err := config.ParseDuration("configuration_max_stale", oidc.ConfigurationMaxStale, defaultTTL)
+	if err != nil {
+		return nil, err
 	}
 
 	ctx := context.Background()
@@ -64,9 +64,8 @@ func NewConfig(oidc *config.OIDC, backends map[string]http.RoundTripper) (*Confi
 		context:  ctx,
 	}
 
-	var err error
 	conf.syncedJSON, err = jsn.NewSyncedJSON("", "",
-		oidc.ConfigurationURL, backends["configuration_backend"], oidc.Name, ttl, conf)
+		oidc.ConfigurationURL, backends["configuration_backend"], oidc.Name, ttl, maxStale, conf)
 	if err != nil {
 		return nil, err
 	}
@@ -182,7 +181,7 @@ func (c *Config) Unmarshal(rawJSON []byte) (interface{}, error) {
 		c.backends["jwks_uri_backend"],
 	)
 
-	newJWKS, err := jwk.NewJWKS(jsonData.JwksUri, c.OIDC.ConfigurationTTL, jwksBackend)
+	newJWKS, err := jwk.NewJWKS(jsonData.JwksUri, c.OIDC.JWKsTTL, c.OIDC.JWKsMaxStale, jwksBackend)
 	if err != nil { // do not replace possible working jwks on err
 		return jsonData, err
 	}
diff --git a/vendor/github.com/dgrijalva/jwt-go/v4/errors.go b/vendor/github.com/dgrijalva/jwt-go/v4/errors.go
index d7a53ba7..0496d03b 100644
--- a/vendor/github.com/dgrijalva/jwt-go/v4/errors.go
+++ b/vendor/github.com/dgrijalva/jwt-go/v4/errors.go
@@ -34,12 +34,12 @@ func wrapError(a, b error) error {
 type ErrorWrapper struct{ err error }
 
 // Unwrap implements xerrors.Wrapper
-func (w ErrorWrapper) Unwrap() error {
+func (w *ErrorWrapper) Unwrap() error {
 	return w.err
 }
 
 // Wrap stores the provided error value and returns it when Unwrap is called
-func (w ErrorWrapper) Wrap(err error) {
+func (w *ErrorWrapper) Wrap(err error) {
 	w.err = err
 }
 


Test patch:
diff --git a/accesscontrol/jwk/jwks_test.go b/accesscontrol/jwk/jwks_test.go
index f98bbfb1..ede40257 100644
--- a/accesscontrol/jwk/jwks_test.go
+++ b/accesscontrol/jwk/jwks_test.go
@@ -41,7 +41,7 @@ func Test_JWKS(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			_, err := jwk.NewJWKS(tt.url, "", backend)
+			_, err := jwk.NewJWKS(tt.url, "", "", backend)
 			if err == nil && tt.error != "" {
 				subT.Errorf("Missing error:\n\tWant: %v\n\tGot:  %v", tt.error, nil)
 			}
@@ -64,7 +64,7 @@ func Test_JWKS_Load(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			jwks, err := jwk.NewJWKS("file:"+tt.file, "", nil)
+			jwks, err := jwk.NewJWKS("file:"+tt.file, "", "", nil)
 			helper.Must(err)
 			_, err = jwks.Data()
 			if err != nil && tt.expParsed {
@@ -93,7 +93,7 @@ func Test_JWKS_GetSigKeyForToken(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
 			helper := test.New(subT)
-			jwks, err := jwk.NewJWKS("file:"+tt.file, "", nil)
+			jwks, err := jwk.NewJWKS("file:"+tt.file, "", "", nil)
 			helper.Must(err)
 			_, err = jwks.Data()
 			helper.Must(err)
@@ -145,7 +145,7 @@ func Test_JWKS_GetKey(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
 			helper := test.New(subT)
-			jwks, err := jwk.NewJWKS("file:"+tt.file, "", nil)
+			jwks, err := jwk.NewJWKS("file:"+tt.file, "", "", nil)
 			helper.Must(err)
 			_, err = jwks.Data()
 			helper.Must(err)
@@ -175,7 +175,7 @@ func Test_JWKS_LoadSynced(t *testing.T) {
 		io.Copy(writer, bytes.NewReader(f))
 	}))
 
-	jwks, err := jwk.NewJWKS(jwksOrigin.URL, "10s", http.DefaultTransport)
+	jwks, err := jwk.NewJWKS(jwksOrigin.URL, "10s", "", http.DefaultTransport)
 	helper.Must(err)
 
 	wg := sync.WaitGroup{}
diff --git a/accesscontrol/jwt_test.go b/accesscontrol/jwt_test.go
index c1fc0ef6..cf853108 100644
--- a/accesscontrol/jwt_test.go
+++ b/accesscontrol/jwt_test.go
@@ -811,12 +811,12 @@ func TestJwtConfig(t *testing.T) {
 			server "test" {}
 			definitions {
 			  jwt "myac" {
-			    jwks_url = "http://no-back.end"
+			    jwks_url = "file://...",
 			    header = "..."
 			  }
 			}
 			`,
-			`backend error: anonymous_5_8_jwks_url: connecting to anonymous_5_8_jwks_url "no-back.end:80" failed: dial tcp: lookup no-back.end`,
+			"",
 		},
 		{
 			"signature_algorithm + jwks_url",
diff --git a/config/duration_test.go b/config/duration_test.go
new file mode 100644
index 00000000..be1c3c56
--- /dev/null
+++ b/config/duration_test.go
@@ -0,0 +1,39 @@
+package config_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/avenga/couper/config"
+)
+
+func TestParseDuration(t *testing.T) {
+	tests := []struct {
+		duration string
+		_default time.Duration
+		want     time.Duration
+		err      string
+	}{
+		{"1s", time.Hour, time.Second, ""},
+		{"0m", time.Hour, 0, ""},
+		{"1h1s1m", time.Hour, 3661 * time.Second, ""},
+		{"", time.Hour, time.Hour, ""},
+		{"invalid", time.Hour, 0, `my-duration: time: invalid duration "invalid"`},
+		{"1sec", time.Hour, 0, `my-duration: time: unknown unit "sec" in duration "1sec"`},
+		{"-3s", time.Hour, 0, `my-duration: cannot be negative: "-3s"`},
+	}
+	for _, tt := range tests {
+		t.Run("", func(subT *testing.T) {
+			duration, err := config.ParseDuration("my-duration", tt.duration, tt._default)
+			if duration != tt.want {
+				subT.Errorf("unexpected duration, want: %q, got: %q", tt.want, duration)
+			}
+			if err != nil && err.Error() != tt.err {
+				subT.Errorf("unexpected error,\n\twant: %q\n\tgot:  %q", tt.err, err.Error())
+			}
+			if err == nil && tt.err != "" {
+				subT.Errorf("expected error %q, got: %v", tt.err, nil)
+			}
+		})
+	}
+}
diff --git a/handler/transport/transport_test.go b/handler/transport/transport_test.go
index e3afed7e..370e95fa 100644
--- a/handler/transport/transport_test.go
+++ b/handler/transport/transport_test.go
@@ -12,7 +12,7 @@ func Test_parseDuration(t *testing.T) {
 	log := logrus.New()
 
 	var target time.Duration
-	parseDuration("1ms", &target, log.WithContext(context.TODO()))
+	parseDuration("1ms", &target, "my-timeout", log.WithContext(context.TODO()))
 
 	if target != 1000000 {
 		t.Errorf("Unexpected duration given: %#v", target)
diff --git a/internal/test/test_backend.go b/internal/test/test_backend.go
index c04f6e51..9365ac39 100644
--- a/internal/test/test_backend.go
+++ b/internal/test/test_backend.go
@@ -234,6 +234,16 @@ func health(rw http.ResponseWriter, req *http.Request) {
 }
 
 func jwks(rw http.ResponseWriter, req *http.Request) {
+	destruct := req.Header.Get("Self-Destruct")
+	if destruct != "" {
+		seconds, _ := strconv.ParseInt(destruct, 10, 64)
+		if time.Now().After(time.Unix(seconds, 0)) {
+			rw.WriteHeader(http.StatusInternalServerError)
+			rw.Write([]byte(`{"booom":1}`))
+			return
+		}
+	}
+
 	_, currFile, _, _ := runtime.Caller(0)
 	rootDir := http.Dir(path.Join(path.Dir(currFile), "testdata"))
 
diff --git a/json/synced_json_test.go b/json/synced_json_test.go
new file mode 100644
index 00000000..3ed046d9
--- /dev/null
+++ b/json/synced_json_test.go
@@ -0,0 +1,88 @@
+package json_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/avenga/couper/internal/test"
+	jsn "github.com/avenga/couper/json"
+)
+
+type unmarshaller struct {
+	jsn.SyncedJSON
+}
+
+type data struct {
+	Foo int `json:"foo"`
+}
+
+func (u *unmarshaller) Unmarshal(rawJSON []byte) (interface{}, error) {
+	jsonData := &data{}
+	err := json.Unmarshal(rawJSON, jsonData)
+	return jsonData, err
+}
+
+func Test_LoadSynced(t *testing.T) {
+	helper := test.New(t)
+
+	requestCount := 0
+	origin := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+		requestCount++
+		if requestCount == 2 || requestCount == 3 || requestCount == 4 {
+			response.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+
+		response.Header().Set("Content-Type", "application/json")
+		response.WriteHeader(http.StatusOK)
+		response.Write([]byte(`{"foo": ` + fmt.Sprintf("%d", requestCount) + `}`))
+	}))
+	defer origin.Close()
+
+	syncedJSON, err := jsn.NewSyncedJSON("", "", origin.URL, http.DefaultTransport, "test", time.Second*2, time.Hour, &unmarshaller{})
+	helper.Must(err)
+
+	expectJSONValue := func(expectedValue int, shouldFail bool) {
+		o, err := syncedJSON.Data()
+		if err == nil && shouldFail {
+			t.Fatalf("expected sync to fail - backoff too small!?")
+		} else if err != nil && !shouldFail {
+			t.Fatalf("unexpected sync failure - backoff too large? %v", err)
+		}
+
+		if o == nil {
+			t.Fatalf("expected JSON response, got: nil")
+		}
+
+		object := o.(*data)
+		if object.Foo != expectedValue {
+			t.Fatalf("unexpected JSON value, want: %d, got: %v", expectedValue, object.Foo)
+		}
+	}
+
+	// First request, JSON response cached for 2s
+	for i := 0; i < 2; i++ {
+		expectJSONValue(1, false)
+		time.Sleep(time.Millisecond * 900)
+	}
+
+	time.Sleep(time.Millisecond * 300)
+
+	// TTL (2s) has expired --> 2nd request fails!
+	// Subsequent requests after 1s (3rd request) and after 3s (4th) fail, too:
+	// --> old JSON is returned for 7s
+	for i := 0; i < 7; i++ {
+		expectJSONValue(1, true)
+		time.Sleep(time.Second)
+	}
+
+	// another 4s later (7s after expiry), the 5th request finally succeeds:
+	expectJSONValue(5, false)
+	time.Sleep(2100 * time.Millisecond)
+	// After 2 more seconds, refresh again:
+	expectJSONValue(6, false)
+}
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 4525bfcf..0296814c 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -3453,10 +3453,10 @@ func TestJWTAccessControl(t *testing.T) {
 		{"RSA JWT PKCS1", "/jwt/rsa/pkcs1", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusOK, "", ""},
 		{"RSA JWT PKCS8", "/jwt/rsa/pkcs8", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusOK, "", ""},
 		{"RSA JWT bad algorithm", "/jwt/rsa/bad", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusForbidden, "", "access control error: RSATokenWrongAlgorithm: token is unverifiable: signing method RS256 is invalid"},
-		{"local RSA JWKS without kid", "/jwks/rsa", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEyMzQ1Njc4OTB9.V9skZUql-mHqwOzVdzamqAOWSx8fjEA-6py0nfxLRSl7h1bQvqUCWMZUAkMJK6RuJ3y5YAr8ZBXZsh4rwABp_3hitQitMXnV6nr5qfzVDE9-mdS4--Bj46-JlkHacNcK24qlnn_EXGJlzCj6VFgjObSy6geaTY9iDVF6EzjZkxc1H75XRlNYAMu-0KCGfKdte0qASeBKrWnoFNEpnXZ_jhqRRNVkaSBj7_HPXD6oPqKBQf6Jh6fGgdz6q4KNL-t-Qa2_eKc8tkrYNdTdxco-ufmmLiUQ_MzRAqowHb2LdsFJP9rN2QT8MGjRXqGvkCd0EsLfqAeCPkTXs1kN8LGlvw"}}, "", http.StatusForbidden, "", `access control error: JWKS: token is unverifiable: Keyfunc returned an error`},
-		{"local RSA JWKS with unsupported kid", "/jwks/rsa", http.Header{"Authorization": []string{"Bearer eyJraWQiOiJyczI1Ni11bnN1cHBvcnRlZCIsImFsZyI6IlJTMjU2IiwidHlwIjoiSldUIn0.eyJzdWIiOjEyMzQ1Njc4OTB9.wx1MkMgJhh6gnOvvrnnkRpEUDe-0KpKWw9ZIfDVHtGkuL46AktBgfbaW1ttB78wWrIW9OPfpLqKwkPizwfShoXKF9qN-6TlhPSWIUh0_kBHEj7H4u45YZXH1Ha-r9kGzly1PmLx7gzxUqRpqYnwo0TzZSEr_a8rpfWaC0ZJl3CKARormeF3tzW_ARHnGUqck4VjPfX50Ot6B5nool6qmsCQLLmDECIKBDzZicqdeWH7JPvRZx45R5ZHJRQpD3Z2iqVIF177Wj1C8q75Gxj2PXziIVKplmIUrKN-elYj3kBtJkDFneb384FPLuzsQZOR6HQmKXG2nA1WOfsblJSz3FA"}}, "", http.StatusForbidden, "", "access control error: JWKS: token is unverifiable: Keyfunc returned an error"},
-		{"local RSA JWKS with non-parsable cert", "/jwks/rsa", http.Header{"Authorization": []string{"Bearer eyJraWQiOiJyczI1Ni13cm9uZy1jZXJ0IiwiYWxnIjoiUlMyNTYiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOjEyMzQ1Njc4OTB9.n--6mjzfnPKbaYAquBK3v6gsbmvEofSprk3jwWGSKPdDt2VpVOe8ZNtGhJj_3f1h86-wg-gEQT5GhJmsI47X9MJ70j74dqhXUF6w4782OljstP955whuSM9hJAIvUw_WV1sqtkiESA-CZiNJIBydL5YzV2nO3gfEYdy9EdMJ2ykGLRBajRxhShxsfaZykFKvvWpy1LbUc-gfRZ4q8Hs9B7b_9RGdbpRwBtwiqPPzhjC5O86vk7ZoiG9Gq7pg52yEkLqdN4a5QkfP8nNeTTMAsqPQL1-1TAC7rIGekoUtoINRR-cewPpZ_E7JVxXvBVvPe3gX_2NzGtXkLg5QDt6RzQ"}}, "", http.StatusForbidden, "", "access control error: JWKS: token is unverifiable: Keyfunc returned an error"},
-		{"local RSA JWKS not found", "/jwks/rsa/not_found", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusForbidden, "", "access control error: JWKS_not_found: token is unverifiable: Keyfunc returned an error"},
+		{"local RSA JWKS without kid", "/jwks/rsa", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEyMzQ1Njc4OTB9.V9skZUql-mHqwOzVdzamqAOWSx8fjEA-6py0nfxLRSl7h1bQvqUCWMZUAkMJK6RuJ3y5YAr8ZBXZsh4rwABp_3hitQitMXnV6nr5qfzVDE9-mdS4--Bj46-JlkHacNcK24qlnn_EXGJlzCj6VFgjObSy6geaTY9iDVF6EzjZkxc1H75XRlNYAMu-0KCGfKdte0qASeBKrWnoFNEpnXZ_jhqRRNVkaSBj7_HPXD6oPqKBQf6Jh6fGgdz6q4KNL-t-Qa2_eKc8tkrYNdTdxco-ufmmLiUQ_MzRAqowHb2LdsFJP9rN2QT8MGjRXqGvkCd0EsLfqAeCPkTXs1kN8LGlvw"}}, "", http.StatusForbidden, "", `access control error: JWKS: no matching RS256 JWK for kid ""`},
+		{"local RSA JWKS with unsupported kid", "/jwks/rsa", http.Header{"Authorization": []string{"Bearer eyJraWQiOiJyczI1Ni11bnN1cHBvcnRlZCIsImFsZyI6IlJTMjU2IiwidHlwIjoiSldUIn0.eyJzdWIiOjEyMzQ1Njc4OTB9.wx1MkMgJhh6gnOvvrnnkRpEUDe-0KpKWw9ZIfDVHtGkuL46AktBgfbaW1ttB78wWrIW9OPfpLqKwkPizwfShoXKF9qN-6TlhPSWIUh0_kBHEj7H4u45YZXH1Ha-r9kGzly1PmLx7gzxUqRpqYnwo0TzZSEr_a8rpfWaC0ZJl3CKARormeF3tzW_ARHnGUqck4VjPfX50Ot6B5nool6qmsCQLLmDECIKBDzZicqdeWH7JPvRZx45R5ZHJRQpD3Z2iqVIF177Wj1C8q75Gxj2PXziIVKplmIUrKN-elYj3kBtJkDFneb384FPLuzsQZOR6HQmKXG2nA1WOfsblJSz3FA"}}, "", http.StatusForbidden, "", `access control error: JWKS: no matching RS256 JWK for kid "rs256-unsupported"`},
+		{"local RSA JWKS with non-parsable cert", "/jwks/rsa", http.Header{"Authorization": []string{"Bearer eyJraWQiOiJyczI1Ni13cm9uZy1jZXJ0IiwiYWxnIjoiUlMyNTYiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOjEyMzQ1Njc4OTB9.n--6mjzfnPKbaYAquBK3v6gsbmvEofSprk3jwWGSKPdDt2VpVOe8ZNtGhJj_3f1h86-wg-gEQT5GhJmsI47X9MJ70j74dqhXUF6w4782OljstP955whuSM9hJAIvUw_WV1sqtkiESA-CZiNJIBydL5YzV2nO3gfEYdy9EdMJ2ykGLRBajRxhShxsfaZykFKvvWpy1LbUc-gfRZ4q8Hs9B7b_9RGdbpRwBtwiqPPzhjC5O86vk7ZoiG9Gq7pg52yEkLqdN4a5QkfP8nNeTTMAsqPQL1-1TAC7rIGekoUtoINRR-cewPpZ_E7JVxXvBVvPe3gX_2NzGtXkLg5QDt6RzQ"}}, "", http.StatusForbidden, "", `access control error: JWKS: no matching RS256 JWK for kid "rs256-wrong-cert"`},
+		{"local RSA JWKS not found", "/jwks/rsa/not_found", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusForbidden, "", `access control error: JWKS_not_found: received no valid JWKs data: <nil>, status code 404`},
 		{"local RSA JWKS", "/jwks/rsa", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusOK, "", ""},
 		{"local RSA JWKS with scope", "/jwks/rsa/scope", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InJzMjU2IiwidHlwIjoiSldUIn0.eyJzdWIiOjEyMzQ1Njc4OTAsInNjb3BlIjpbImZvbyIsImJhciJdfQ.IFqIF_9ELXl3A-oy52G0Sg5f34ah3araOxFboskEw110nXdb_-UuxCnG0naFVFje7xvNrGbJgVAbBRX1v1I_to4BR8RzvIh2hi5IgBmqclIYsYbVWlEhsvjBhFR2b90Rz0APUdfgHp-nvgLB13jxm8f4TRr4ZDnvUQdZp3vI5PMj9optEmlZvexkNLDQLrBvoGCfVHodZyPQMLNVKp0TXWksPT-bw0E7Lq1GeYe2eU0GwHx8fugo2-v44dfCp0RXYYG6bI_Z-U3KZpvdj05n2_UDgTJFFm4c5i9UjILvlO73QJpMNi5eBjerm2alTisSCoiCtfgIgVsM8yHoomgarg"}}, "", http.StatusOK, `["foo","bar"]`, ""},
 		{"remote RSA JWKS x5c", "/jwks/rsa/remote", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusOK, "", ""},
@@ -3518,6 +3518,78 @@ func TestJWTAccessControl(t *testing.T) {
 	}
 }
 
+func TestJWKsMaxStale(t *testing.T) {
+	helper := test.New(t)
+	client := newClient()
+
+	config := `
+	  server {
+	    endpoint "/" {
+	    access_control = ["stale"]
+	      response {
+	        body = "hi"
+	      }
+	    }
+	  }
+	  definitions {
+	    jwt "stale" {
+	      jwks_url = "${env.COUPER_TEST_BACKEND_ADDR}/jwks.json"
+	      jwks_ttl = "3s"
+	      jwks_max_stale = "2s"
+	      backend {
+	        origin = env.COUPER_TEST_BACKEND_ADDR
+	        set_request_headers = {
+	          Self-Destruct: ` + fmt.Sprint(time.Now().Add(2*time.Second).Unix()) + `
+	        }
+	      }
+	    }
+	  }
+	`
+
+	shutdown, hook := newCouperWithBytes([]byte(config), helper)
+	defer shutdown()
+
+	req, err := http.NewRequest(http.MethodGet, "http://back.end:8080/", nil)
+	helper.Must(err)
+
+	rsaToken := "eyJhbGciOiJSUzI1NiIsImtpZCI6InJzMjU2IiwidHlwIjoiSldUIn0.eyJzdWIiOjEyMzQ1Njc4OTB9.AZ0gZVqPe9TjjjJO0GnlTvERBXhPyxW_gTn050rCoEkseFRlp4TYry7WTQ7J4HNrH3btfxaEQLtTv7KooVLXQyMDujQbKU6cyuYH6MZXaM0Co3Bhu0awoX-2GVk997-7kMZx2yvwIR5ypd1CERIbNs5QcQaI4sqx_8oGrjO5ZmOWRqSpi4Mb8gJEVVccxurPu65gPFq9esVWwTf4cMQ3GGzijatnGDbRWs_igVGf8IAfmiROSVd17fShQtfthOFd19TGUswVAleOftC7-DDeJgAK8Un5xOHGRjv3ypK_6ZLRonhswaGXxovE0kLq4ZSzumQY2hOFE6x_BbrR1WKtGw"
+
+	req.Header = http.Header{"Authorization": []string{"Bearer " + rsaToken}}
+
+	res, err := client.Do(req)
+	helper.Must(err)
+	if res.StatusCode != 200 {
+		message := getAccessControlMessages(hook)
+		t.Fatalf("expected status %d, got: %d (%s)", 200, res.StatusCode, message)
+	}
+
+	time.Sleep(3 * time.Second)
+	// TTL 3s, backend is already failing, responds with stale JWKS
+
+	res, err = client.Do(req)
+	helper.Must(err)
+	if res.StatusCode != 200 {
+		message := getAccessControlMessages(hook)
+		t.Fatalf("expected status %d, got: %d (%s)", 200, res.StatusCode, message)
+	}
+
+	time.Sleep(3 * time.Second)
+	// stale time (2s) exhausted -> 403
+	res, err = client.Do(req)
+	helper.Must(err)
+
+	time.Sleep(time.Second)
+	message := getAccessControlMessages(hook)
+	if res.StatusCode != 403 {
+		t.Fatalf("expected status %d, got: %d (%s)", 403, res.StatusCode, message)
+	}
+
+	expectedMessage := "access control error: stale: received no valid JWKs data: <nil>, status code 500"
+	if message != expectedMessage {
+		t.Fatalf("expected message %q, got: %q", expectedMessage, message)
+	}
+}
+
 func TestJWTAccessControlSourceConfig(t *testing.T) {
 	helper := test.New(t)
 	couperConfig, err := configload.LoadFiles("testdata/integration/config/05_couper.hcl", "")
diff --git a/server/http_test.go b/server/http_test.go
index 76125d49..becae02f 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -706,7 +706,7 @@ func TestHTTPServer_parseDuration(t *testing.T) {
 
 	logs := logHook.AllEntries()
 
-	if logs[0].Message != `using default timing of 0s because an error occured: time: invalid duration "xxx"` {
+	if logs[0].Message != `using default timing of 0s because an error occured: timeout: time: invalid duration "xxx"` {
 		t.Errorf("%#v", logs[0].Message)
 	}
 }