Repository: avenga/couper
Pull request #404 (2021-12-03 14:57:59)

OIDC ID token signature check
Couper now checks the signature of ID tokens

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 429ae4f5..065b5c3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
 * **Changed**
   * Missing [scope or roles claims](./docs/REFERENCE.md#jwt-block), or scope or roles claim with unsupported values are now ignored instead of causing an error ([#380](https://github.com/avenga/couper/issues/380))
   * Improved the validation for unique keys in all map-attributes in the config ([#403](https://github.com/avenga/couper/pull/403))
+  * The access control for the OIDC redirect endpoint ([`beta_oidc` block](./docs/REFERENCE.md#oidc-block-beta)) now verifies ID token signatures ([#404](https://github.com/avenga/couper/pull/404))
 
 * **Fixed**
   * build-date configuration for binary and docker builds ([#396](https://github.com/avenga/couper/pull/396))
diff --git a/accesscontrol/jwk.go b/accesscontrol/jwk/jwk.go
similarity index 97%
rename from accesscontrol/jwk.go
rename to accesscontrol/jwk/jwk.go
index c68f57e3..13aa4a6e 100644
--- a/accesscontrol/jwk.go
+++ b/accesscontrol/jwk/jwk.go
@@ -1,4 +1,4 @@
-package accesscontrol
+package jwk
 
 import (
 	"bytes"
@@ -16,6 +16,7 @@ import (
 type JWK struct {
 	Key       interface{}
 	KeyID     string
+	KeyType   string
 	Algorithm string
 	Use       string
 }
@@ -47,7 +48,7 @@ func (j *JWK) UnmarshalJSON(data []byte) error {
 	}
 
 	var key interface{}
-	jwk := JWK{KeyID: raw.Kid, Algorithm: raw.Alg, Use: raw.Use}
+	jwk := JWK{KeyID: raw.Kid, Algorithm: raw.Alg, KeyType: raw.Kty, Use: raw.Use}
 
 	switch raw.Kty {
 	case "RSA":
diff --git a/accesscontrol/jwk/jwks.go b/accesscontrol/jwk/jwks.go
new file mode 100644
index 00000000..4cb2b366
--- /dev/null
+++ b/accesscontrol/jwk/jwks.go
@@ -0,0 +1,134 @@
+package jwk
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/dgrijalva/jwt-go/v4"
+
+	jsn "github.com/avenga/couper/json"
+)
+
+var alg2kty = map[string]string{
+	"RS256": "RSA",
+	"RS384": "RSA",
+	"RS512": "RSA",
+	"ES256": "EC",
+	"ES384": "EC",
+	"ES512": "EC",
+}
+
+type JWKSData struct {
+	Keys []JWK `json:"keys"`
+}
+
+type JWKS struct {
+	syncedJSON *jsn.SyncedJSON
+}
+
+func NewJWKS(uri string, ttl string, transport http.RoundTripper, confContext context.Context) (*JWKS, error) {
+	if ttl == "" {
+		ttl = "1h"
+	}
+
+	timetolive, err := time.ParseDuration(ttl)
+	if err != nil {
+		return nil, err
+	}
+	var file string
+	if strings.HasPrefix(uri, "file:") {
+		file = uri[5:]
+	} else if !strings.HasPrefix(uri, "http:") && !strings.HasPrefix(uri, "https:") {
+		return nil, fmt.Errorf("unsupported JWKS URI scheme: %q", uri)
+	}
+
+	jwks := &JWKS{}
+	sj := jsn.NewSyncedJSON(confContext, file, "jwks_url", uri, transport, "jwks" /* TODO which roundtrip name? */, timetolive, jwks)
+	jwks.syncedJSON = sj
+	return jwks, nil
+}
+
+func (j *JWKS) GetSigKeyForToken(token *jwt.Token) (interface{}, error) {
+	algorithm := token.Header["alg"]
+	if algorithm == nil {
+		return nil, fmt.Errorf("missing \"alg\" in JOSE header")
+	}
+	id := token.Header["kid"]
+	if id == nil {
+		id = ""
+	}
+	jwk, err := j.GetKey(id.(string), algorithm.(string), "sig")
+	if err != nil {
+		return nil, err
+	}
+
+	if jwk == nil {
+		return nil, fmt.Errorf("no matching %s JWK for kid %q", algorithm, id)
+	}
+
+	return jwk.Key, nil
+}
+
+func (j *JWKS) GetKeys(kid string) ([]JWK, error) {
+	var keys []JWK
+
+	jwksData, err := j.Data("")
+	if err != nil {
+		return nil, err
+	}
+
+	allKeys := jwksData.Keys
+	for _, key := range allKeys {
+		if key.KeyID == kid {
+			keys = append(keys, key)
+		}
+	}
+
+	return keys, nil
+}
+
+func (j *JWKS) GetKey(kid string, alg string, use string) (*JWK, error) {
+	keys, err := j.GetKeys(kid)
+	if err != nil {
+		return nil, err
+	}
+	for _, key := range keys {
+		if key.Use == use {
+			if key.Algorithm == alg {
+				return &key, nil
+			} else if key.Algorithm == "" {
+				if kty, exists := alg2kty[alg]; exists && key.KeyType == kty {
+					return &key, nil
+				}
+			}
+		}
+	}
+	return nil, nil
+}
+
+func (j *JWKS) Data(uid string) (*JWKSData, error) {
+	data, err := j.syncedJSON.Data(uid)
+	if err != nil {
+		return nil, err
+	}
+
+	jwksData, ok := data.(JWKSData)
+	if !ok {
+		return nil, fmt.Errorf("data not JWKS data: %#v", data)
+	}
+
+	return &jwksData, nil
+}
+
+func (j *JWKS) Unmarshal(rawJSON []byte, _ string) (interface{}, error) {
+	var jsonData JWKSData
+	err := json.Unmarshal(rawJSON, &jsonData)
+	if err != nil {
+		return nil, err
+	}
+	return jsonData, nil
+}
diff --git a/accesscontrol/testdata/jwks.json b/accesscontrol/jwk/testdata/jwks.json
similarity index 100%
rename from accesscontrol/testdata/jwks.json
rename to accesscontrol/jwk/testdata/jwks.json
diff --git a/accesscontrol/testdata/jwks_ecdsa.json b/accesscontrol/jwk/testdata/jwks_ecdsa.json
similarity index 100%
rename from accesscontrol/testdata/jwks_ecdsa.json
rename to accesscontrol/jwk/testdata/jwks_ecdsa.json
diff --git a/accesscontrol/testdata/jwks_no_alg.json b/accesscontrol/jwk/testdata/jwks_no_alg.json
similarity index 100%
rename from accesscontrol/testdata/jwks_no_alg.json
rename to accesscontrol/jwk/testdata/jwks_no_alg.json
diff --git a/accesscontrol/testdata/jwks_no_kid.json b/accesscontrol/jwk/testdata/jwks_no_kid.json
similarity index 100%
rename from accesscontrol/testdata/jwks_no_kid.json
rename to accesscontrol/jwk/testdata/jwks_no_kid.json
diff --git a/accesscontrol/testdata/jwks_no_kid_no_alg.json b/accesscontrol/jwk/testdata/jwks_no_kid_no_alg.json
similarity index 100%
rename from accesscontrol/testdata/jwks_no_kid_no_alg.json
rename to accesscontrol/jwk/testdata/jwks_no_kid_no_alg.json
diff --git a/accesscontrol/testdata/jwks_no_kid_no_alg_no_use.json b/accesscontrol/jwk/testdata/jwks_no_kid_no_alg_no_use.json
similarity index 100%
rename from accesscontrol/testdata/jwks_no_kid_no_alg_no_use.json
rename to accesscontrol/jwk/testdata/jwks_no_kid_no_alg_no_use.json
diff --git a/accesscontrol/testdata/jwks_no_use.json b/accesscontrol/jwk/testdata/jwks_no_use.json
similarity index 100%
rename from accesscontrol/testdata/jwks_no_use.json
rename to accesscontrol/jwk/testdata/jwks_no_use.json
diff --git a/accesscontrol/testdata/jwks_oct.json b/accesscontrol/jwk/testdata/jwks_oct.json
similarity index 100%
rename from accesscontrol/testdata/jwks_oct.json
rename to accesscontrol/jwk/testdata/jwks_oct.json
diff --git a/accesscontrol/jwks.go b/accesscontrol/jwks.go
deleted file mode 100644
index b35df915..00000000
--- a/accesscontrol/jwks.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package accesscontrol
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"strings"
-	"sync"
-	"time"
-
-	"github.com/avenga/couper/config/reader"
-	"github.com/avenga/couper/config/request"
-)
-
-type JWKS struct {
-	Keys      []JWK `json:"keys"`
-	context   context.Context
-	expiry    int64
-	file      string
-	uri       string
-	transport http.RoundTripper
-	ttl       time.Duration
-	mtx       sync.RWMutex
-}
-
-func NewJWKS(uri string, ttl string, transport http.RoundTripper, confContext context.Context) (*JWKS, error) {
-	if ttl == "" {
-		ttl = "1h"
-	}
-
-	timetolive, err := time.ParseDuration(ttl)
-	if err != nil {
-		return nil, err
-	}
-	var file string
-	if strings.HasPrefix(uri, "file:") {
-		file = uri[5:]
-	} else if !strings.HasPrefix(uri, "http:") && !strings.HasPrefix(uri, "https:") {
-		return nil, fmt.Errorf("unsupported JWKS URI scheme: %q", uri)
-	}
-
-	return &JWKS{
-		context:   confContext,
-		file:      file,
-		uri:       uri,
-		transport: transport,
-		ttl:       timetolive,
-	}, nil
-}
-
-func (j *JWKS) GetKeys(kid string) ([]JWK, error) {
-	var (
-		keys []JWK
-		err  error
-	)
-
-	j.mtx.RLock()
-	allKeys := j.Keys
-	expired := j.hasExpired()
-	j.mtx.RUnlock()
-	if len(allKeys) == 0 || expired {
-		allKeys, err = j.Load()
-		if err != nil {
-			return keys, fmt.Errorf("error loading JWKS: %v", err)
-		}
-	}
-
-	for _, key := range allKeys {
-		if key.KeyID == kid {
-			keys = append(keys, key)
-		}
-	}
-
-	return keys, nil
-}
-
-func (j *JWKS) GetKey(kid string, alg string, use string) (*JWK, error) {
-	keys, err := j.GetKeys(kid)
-	if err != nil {
-		return nil, err
-	}
-	for _, key := range keys {
-		if key.Algorithm == alg && key.Use == use {
-			return &key, nil
-		}
-	}
-	return nil, nil
-}
-
-func (j *JWKS) Load() ([]JWK, error) {
-	var rawJSON []byte
-
-	if j.file != "" {
-		j, err := reader.ReadFromFile("jwks_url", j.file)
-		if err != nil {
-			return nil, err
-		}
-		rawJSON = j
-	} else if j.transport != nil {
-		req, err := http.NewRequest("GET", "", nil)
-		if err != nil {
-			return nil, err
-		}
-		ctx := context.WithValue(j.context, request.URLAttribute, j.uri)
-		// TODO which roundtrip name?
-		ctx = context.WithValue(ctx, request.RoundTripName, "jwks")
-		req = req.WithContext(ctx)
-		response, err := j.transport.RoundTrip(req)
-		if err != nil {
-			return nil, err
-		}
-		if response.StatusCode != 200 {
-			return nil, fmt.Errorf("status code %d", response.StatusCode)
-		}
-
-		defer response.Body.Close()
-
-		body, err := ioutil.ReadAll(response.Body)
-		if err != nil {
-			return nil, fmt.Errorf("error reading JWKS response for %q: %v", j.uri, err)
-		}
-		rawJSON = body
-	} else {
-		return nil, fmt.Errorf("jwks: missing both file and request")
-	}
-
-	var jwks JWKS
-	err := json.Unmarshal(rawJSON, &jwks)
-	if err != nil {
-		return nil, err
-	}
-
-	j.mtx.Lock()
-	defer j.mtx.Unlock()
-
-	j.Keys = jwks.Keys
-	j.expiry = time.Now().Unix() + int64(j.ttl.Seconds())
-
-	return j.Keys, nil
-}
-
-func (jwks *JWKS) hasExpired() bool {
-	return time.Now().Unix() > jwks.expiry
-}
diff --git a/accesscontrol/jwt.go b/accesscontrol/jwt.go
index fe708c2d..44f03483 100644
--- a/accesscontrol/jwt.go
+++ b/accesscontrol/jwt.go
@@ -15,6 +15,7 @@ import (
 	"github.com/hashicorp/hcl/v2"
 	"github.com/sirupsen/logrus"
 
+	"github.com/avenga/couper/accesscontrol/jwk"
 	acjwt "github.com/avenga/couper/accesscontrol/jwt"
 	"github.com/avenga/couper/config/request"
 	"github.com/avenga/couper/errors"
@@ -51,7 +52,7 @@ type JWT struct {
 	rolesClaim     string
 	rolesMap       map[string][]string
 	scopeClaim     string
-	jwks           *JWKS
+	jwks           *jwk.JWKS
 }
 
 type JWTOptions struct {
@@ -64,7 +65,7 @@ type JWTOptions struct {
 	ScopeClaim     string
 	Source         JWTSource
 	Key            []byte
-	JWKS           *JWKS
+	JWKS           *jwk.JWKS
 }
 
 func NewJWTSource(cookie, header string, value hcl.Expression) JWTSource {
@@ -213,6 +214,11 @@ func (j *JWT) Validate(req *http.Request) error {
 		return err
 	}
 
+	ctx := req.Context()
+	if j.jwks != nil {
+		// load JWKS if needed and associate with request uid
+		j.jwks.Data(ctx.Value(request.UID).(string))
+	}
 	token, err := parser.Parse(tokenValue, j.getValidationKey)
 	if err != nil {
 		switch err := err.(type) {
@@ -231,7 +237,6 @@ func (j *JWT) Validate(req *http.Request) error {
 		return err
 	}
 
-	ctx := req.Context()
 	acMap, ok := ctx.Value(request.AccessControls).(map[string]interface{})
 	if !ok {
 		acMap = make(map[string]interface{})
@@ -253,26 +258,30 @@ func (j *JWT) Validate(req *http.Request) error {
 	return nil
 }
 
-func (j *JWT) getValidationKey(token *jwt.Token) (interface{}, error) {
-	if j.jwks != nil {
-		id := token.Header["kid"]
-		algorithm := token.Header["alg"]
-		if id == nil {
-			return nil, fmt.Errorf("missing \"kid\" in JOSE header")
-		}
-		if algorithm == nil {
-			return nil, fmt.Errorf("missing \"alg\" in JOSE header")
-		}
-		jwk, err := j.jwks.GetKey(id.(string), algorithm.(string), "sig")
-		if err != nil {
-			return nil, err
-		}
+func GetKeyFromJWKS(jwks *jwk.JWKS, token *jwt.Token) (interface{}, error) {
+	id := token.Header["kid"]
+	algorithm := token.Header["alg"]
+	if id == nil {
+		return nil, fmt.Errorf("missing \"kid\" in JOSE header")
+	}
+	if algorithm == nil {
+		return nil, fmt.Errorf("missing \"alg\" in JOSE header")
+	}
+	jwk, err := jwks.GetKey(id.(string), algorithm.(string), "sig")
+	if err != nil {
+		return nil, err
+	}
 
-		if jwk == nil {
-			return nil, fmt.Errorf("no matching %s JWK for kid %q", algorithm, id)
-		}
+	if jwk == nil {
+		return nil, fmt.Errorf("no matching %s JWK for kid %q", algorithm, id)
+	}
+
+	return jwk.Key, nil
+}
 
-		return jwk.Key, nil
+func (j *JWT) getValidationKey(token *jwt.Token) (interface{}, error) {
+	if j.jwks != nil {
+		return j.jwks.GetSigKeyForToken(token)
 	}
 
 	switch j.algorithms[0] {
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 990747a0..c10919f3 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -20,6 +20,7 @@ import (
 	"github.com/sirupsen/logrus"
 
 	ac "github.com/avenga/couper/accesscontrol"
+	"github.com/avenga/couper/accesscontrol/jwk"
 	"github.com/avenga/couper/cache"
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/configload/collect"
@@ -560,7 +561,7 @@ func configureOidcConfigs(conf *config.Couper, confCtx *hcl.EvalContext, log *lo
 				return nil, confErr.With(err)
 			}
 
-			oidcConfig, err := oidc.NewConfig(oidcConf, backend, memStore)
+			oidcConfig, err := oidc.NewConfig(oidcConf, backend)
 			if err != nil {
 				return nil, confErr.With(err)
 			}
@@ -711,7 +712,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
 	return accessControls, nil
 }
 
-func configureJWKS(jwtConf *config.JWT, conf *config.Couper, confContext *hcl.EvalContext, log *logrus.Entry, ignoreProxyEnv bool, memStore *cache.MemoryStore) (*ac.JWKS, error) {
+func configureJWKS(jwtConf *config.JWT, conf *config.Couper, confContext *hcl.EvalContext, log *logrus.Entry, ignoreProxyEnv bool, memStore *cache.MemoryStore) (*jwk.JWKS, error) {
 	var backend http.RoundTripper
 
 	if jwtConf.Backend != nil {
@@ -723,7 +724,7 @@ func configureJWKS(jwtConf *config.JWT, conf *config.Couper, confContext *hcl.Ev
 	}
 
 	evalContext := conf.Context.Value(request.ContextType).(context.Context)
-	jwks, err := ac.NewJWKS(jwtConf.JWKsURL, jwtConf.JWKsTTL, backend, evalContext)
+	jwks, err := jwk.NewJWKS(jwtConf.JWKsURL, jwtConf.JWKsTTL, backend, evalContext)
 	if err != nil {
 		return nil, err
 	}
diff --git a/json/synced_json.go b/json/synced_json.go
new file mode 100644
index 00000000..bc6ca350
--- /dev/null
+++ b/json/synced_json.go
@@ -0,0 +1,120 @@
+package json
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/avenga/couper/config/reader"
+	"github.com/avenga/couper/config/request"
+)
+
+type SyncedJSONUnmarshaller interface {
+	Unmarshal(rawJSON []byte, uid string) (interface{}, error)
+}
+
+type SyncedJSON struct {
+	context       context.Context
+	file          string
+	fileContext   string
+	uri           string
+	transport     http.RoundTripper
+	roundTripName string
+	ttl           time.Duration
+	unmarshaller  SyncedJSONUnmarshaller
+	// used internally
+	data   interface{}
+	expiry int64
+	mtx    sync.RWMutex
+}
+
+func NewSyncedJSON(context context.Context, file, fileContext, uri string, transport http.RoundTripper, roundTripName string, ttl time.Duration, unmarshaller SyncedJSONUnmarshaller) *SyncedJSON {
+	return &SyncedJSON{
+		context:       context,
+		file:          file,
+		fileContext:   fileContext,
+		uri:           uri,
+		transport:     transport,
+		roundTripName: roundTripName,
+		ttl:           ttl,
+		unmarshaller:  unmarshaller,
+	}
+}
+
+func (s *SyncedJSON) Data(uid string) (interface{}, error) {
+	var err error
+
+	s.mtx.RLock()
+	data := s.data
+	expired := s.hasExpired()
+	s.mtx.RUnlock()
+
+	if data == nil || expired {
+		data, err = s.Load(uid)
+		if err != nil {
+			return nil, fmt.Errorf("error loading synced JSON: %v", err)
+		}
+	}
+
+	return data, nil
+}
+
+func (s *SyncedJSON) Load(uid string) (interface{}, error) {
+	var rawJSON []byte
+
+	if s.file != "" {
+		j, err := reader.ReadFromFile(s.fileContext, s.file)
+		if err != nil {
+			return nil, err
+		}
+		rawJSON = j
+	} else if s.transport != nil {
+		req, err := http.NewRequest("GET", "", nil)
+		if err != nil {
+			return nil, err
+		}
+		ctx := context.WithValue(s.context, request.URLAttribute, s.uri)
+		ctx = context.WithValue(ctx, request.RoundTripName, s.roundTripName)
+		if uid != "" {
+			ctx = context.WithValue(ctx, request.UID, uid)
+		}
+		req = req.WithContext(ctx)
+		response, err := s.transport.RoundTrip(req)
+		if err != nil {
+			return nil, err
+		}
+		if response.StatusCode != 200 {
+			return nil, fmt.Errorf("status code %d", response.StatusCode)
+		}
+
+		defer response.Body.Close()
+
+		body, err := ioutil.ReadAll(response.Body)
+		if err != nil {
+			return nil, fmt.Errorf("error reading response for %q: %v", s.uri, err)
+		}
+		rawJSON = body
+	} else {
+		return nil, fmt.Errorf("synced JSON: missing both file and request")
+	}
+
+	jsonData, err := s.unmarshaller.Unmarshal(rawJSON, uid)
+	if err != nil {
+		return nil, err
+	}
+
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
+
+	s.data = jsonData
+	s.expiry = time.Now().Unix() + int64(s.ttl.Seconds())
+
+	return jsonData, nil
+}
+
+func (s *SyncedJSON) hasExpired() bool {
+	return time.Now().Unix() > s.expiry
+}
diff --git a/oauth2/oidc.go b/oauth2/oidc.go
index 2794375a..7c676e52 100644
--- a/oauth2/oidc.go
+++ b/oauth2/oidc.go
@@ -95,7 +95,7 @@ func (o *OidcClient) validateTokenResponseData(ctx context.Context, tokenRespons
 	o.issLock.RUnlock()
 
 	if idTokenString, ok := tokenResponseData["id_token"].(string); ok {
-		idToken, _, err := jwtParser.ParseUnverified(idTokenString, jwt.MapClaims{})
+		idToken, err := jwtParser.Parse(idTokenString, o.config.JWKS.GetSigKeyForToken)
 		if err != nil {
 			return err
 		}
diff --git a/oauth2/oidc/config.go b/oauth2/oidc/config.go
index 2761a567..5850819f 100644
--- a/oauth2/oidc/config.go
+++ b/oauth2/oidc/config.go
@@ -1,28 +1,26 @@
 package oidc
 
 import (
-	"bytes"
 	"context"
-	"crypto/sha256"
 	"encoding/json"
 	"fmt"
-	"io"
 	"net/http"
 	"sync"
 	"time"
 
-	"github.com/avenga/couper/cache"
+	"github.com/avenga/couper/accesscontrol/jwk"
 	"github.com/avenga/couper/config"
-	"github.com/avenga/couper/config/request"
+	jsn "github.com/avenga/couper/json"
 )
 
 // OpenidConfiguration represents an OpenID configuration (.../.well-known/openid-configuration)
 type OpenidConfiguration struct {
 	AuthorizationEndpoint         string   `json:"authorization_endpoint"`
+	CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
 	Issuer                        string   `json:"issuer"`
+	JwksUri                       string   `json:"jwks_uri"`
 	TokenEndpoint                 string   `json:"token_endpoint"`
 	UserinfoEndpoint              string   `json:"userinfo_endpoint"`
-	CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
 }
 
 type Configs map[string]*Config
@@ -39,14 +37,14 @@ var (
 type Config struct {
 	*config.OIDC
 	Backend    http.RoundTripper
-	memStore   *cache.MemoryStore
-	remoteConf *OpenidConfiguration
-	remoteMu   sync.RWMutex
-	ttl        int64
+	context    context.Context
+	syncedJSON *jsn.SyncedJSON
+	JWKS       *jwk.JWKS
+	mtx        sync.RWMutex
 }
 
 // NewConfig creates a new configuration for an OIDC client
-func NewConfig(oidc *config.OIDC, backend http.RoundTripper, memStore *cache.MemoryStore) (*Config, error) {
+func NewConfig(oidc *config.OIDC, backend http.RoundTripper) (*Config, error) {
 	ttl := defaultTTL
 	if oidc.ConfigurationTTL != "" {
 		t, err := time.ParseDuration(oidc.ConfigurationTTL)
@@ -56,146 +54,105 @@ func NewConfig(oidc *config.OIDC, backend http.RoundTripper, memStore *cache.Mem
 		ttl = t
 	}
 
-	return &Config{OIDC: oidc, Backend: backend, memStore: memStore, ttl: (int64)(ttl)}, nil
+	ctx := context.Background()
+	config := &Config{OIDC: oidc, context: ctx, Backend: backend}
+	sj := jsn.NewSyncedJSON(ctx, "", "", oidc.ConfigurationURL, backend, oidc.Name, ttl, config)
+	config.syncedJSON = sj
+	return config, nil
 }
 
 // GetVerifierMethod retrieves the verifier method (ccm_s256 or nonce)
-func (o *Config) GetVerifierMethod(uid string) (string, error) {
-	if o.VerifierMethod == "" {
-		err := o.getFreshIfExpired(uid)
+func (c *Config) GetVerifierMethod(uid string) (string, error) {
+	if c.VerifierMethod == "" {
+		_, err := c.Data(uid)
 		if err != nil {
 			return "", err
 		}
 	}
 
-	return o.VerifierMethod, nil
+	return c.VerifierMethod, nil
 }
 
-func (o *Config) GetAuthorizationEndpoint(uid string) (string, error) {
-	err := o.getFreshIfExpired(uid)
+func (c *Config) GetAuthorizationEndpoint(uid string) (string, error) {
+	openidConfigurationData, err := c.Data(uid)
 	if err != nil {
 		return "", err
 	}
 
-	o.remoteMu.RLock()
-	defer o.remoteMu.RUnlock()
-	return o.remoteConf.AuthorizationEndpoint, nil
+	return openidConfigurationData.AuthorizationEndpoint, nil
 }
 
-func (o *Config) GetIssuer() (string, error) {
-	err := o.getFreshIfExpired("")
+func (c *Config) GetIssuer() (string, error) {
+	openidConfigurationData, err := c.Data("")
 	if err != nil {
 		return "", err
 	}
 
-	o.remoteMu.RLock()
-	defer o.remoteMu.RUnlock()
-	return o.remoteConf.Issuer, nil
+	return openidConfigurationData.Issuer, nil
 }
 
-func (o *Config) GetTokenEndpoint() (string, error) {
-	err := o.getFreshIfExpired("")
+func (c *Config) GetTokenEndpoint() (string, error) {
+	openidConfigurationData, err := c.Data("")
 	if err != nil {
 		return "", err
 	}
 
-	o.remoteMu.RLock()
-	defer o.remoteMu.RUnlock()
-	return o.remoteConf.TokenEndpoint, nil
+	return openidConfigurationData.TokenEndpoint, nil
 }
 
-func (o *Config) GetUserinfoEndpoint() (string, error) {
-	err := o.getFreshIfExpired("")
+func (c *Config) GetUserinfoEndpoint() (string, error) {
+	openidConfigurationData, err := c.Data("")
 	if err != nil {
 		return "", err
 	}
 
-	o.remoteMu.RLock()
-	defer o.remoteMu.RUnlock()
-	return o.remoteConf.UserinfoEndpoint, nil
+	return openidConfigurationData.UserinfoEndpoint, nil
 }
 
-func (o *Config) getFreshIfExpired(uid string) error {
-	key := o.Name + o.ConfigurationURL
-	confVal := o.memStore.Get(key)
-	if oc, ok := confVal.(*OpenidConfiguration); ok {
-
-		o.remoteMu.RLock()
-		if oc.hash() == o.remoteConf.hash() {
-			o.remoteMu.RUnlock()
-			return nil
-		}
-		o.remoteMu.RUnlock()
-
-		o.remoteMu.Lock()
-		o.remoteConf = oc
-		o.remoteMu.Unlock()
-		return nil
-	}
-
-	conf, err := o.fetchConfiguration(uid)
-	if err != nil {
-		return err
-	}
-
-	o.remoteMu.Lock()
-	defer o.remoteMu.Unlock()
-
-	o.remoteConf = conf
-	if o.OIDC.VerifierMethod == "" {
-		if supportsS256(o.remoteConf.CodeChallengeMethodsSupported) {
-			o.OIDC.VerifierMethod = config.CcmS256
-		} else {
-			o.OIDC.VerifierMethod = "nonce"
-		}
-	}
-
-	return nil
-}
-
-func (o *Config) fetchConfiguration(uid string) (*OpenidConfiguration, error) {
-	req, err := http.NewRequest(http.MethodGet, "", nil)
+func (c *Config) Data(uid string) (*OpenidConfiguration, error) {
+	data, err := c.syncedJSON.Data(uid)
 	if err != nil {
 		return nil, err
 	}
 
-	ctx := context.WithValue(context.Background(), request.URLAttribute, o.ConfigurationURL)
-	ctx = context.WithValue(ctx, request.RoundTripName, o.Name)
-	if uid != "" {
-		ctx = context.WithValue(ctx, request.UID, uid)
+	openidConfigurationData, ok := data.(OpenidConfiguration)
+	if !ok {
+		return nil, fmt.Errorf("data not OpenID configuration data: %#v", data)
 	}
-	req = req.WithContext(ctx)
 
-	res, err := o.Backend.RoundTrip(req)
-	if err != nil {
-		return nil, err
-	}
+	return &openidConfigurationData, nil
+}
 
-	ocBytes, err := io.ReadAll(res.Body)
+func (c *Config) Unmarshal(rawJSON []byte, uid string) (interface{}, error) {
+	var jsonData OpenidConfiguration
+	err := json.Unmarshal(rawJSON, &jsonData)
 	if err != nil {
 		return nil, err
 	}
 
-	if len(ocBytes) == 0 {
-		return nil, fmt.Errorf("configuration body is empty: %s", o.ConfigurationURL)
+	c.mtx.RLock()
+	oldVM := c.OIDC.VerifierMethod
+	c.mtx.RUnlock()
+	newVM := oldVM
+	if oldVM == "" {
+		if supportsS256(jsonData.CodeChallengeMethodsSupported) {
+			newVM = config.CcmS256
+		} else {
+			newVM = "nonce"
+		}
 	}
 
-	decoder := json.NewDecoder(bytes.NewReader(ocBytes))
-	openidConfiguration := &OpenidConfiguration{}
-	err = decoder.Decode(openidConfiguration)
+	newJWKS, err := jwk.NewJWKS(jsonData.JwksUri, c.OIDC.ConfigurationTTL, c.Backend, c.context)
 	if err != nil {
 		return nil, err
 	}
+	newJWKS.Data(uid)
+	c.mtx.Lock()
+	c.OIDC.VerifierMethod = newVM
+	c.JWKS = newJWKS
+	c.mtx.Unlock()
 
-	key := o.Name + o.ConfigurationURL
-	o.memStore.Set(key, openidConfiguration, o.ttl)
-	return openidConfiguration, nil
-}
-
-func (rc *OpenidConfiguration) hash() string {
-	h := sha256.New()
-	h.Write([]byte(fmt.Sprintf("%v", rc)))
-	return fmt.Sprintf("%x", h.Sum(nil))
+	return jsonData, nil
 }
 
 func supportsS256(codeChallengeMethodsSupported []string) bool {


Test patch:
diff --git a/accesscontrol/jwks_test.go b/accesscontrol/jwk/jwks_test.go
similarity index 71%
rename from accesscontrol/jwks_test.go
rename to accesscontrol/jwk/jwks_test.go
index c45eeb92..c3e379c5 100644
--- a/accesscontrol/jwks_test.go
+++ b/accesscontrol/jwk/jwks_test.go
@@ -1,10 +1,12 @@
-package accesscontrol_test
+package jwk_test
 
 import (
 	"sync"
 	"testing"
 
-	ac "github.com/avenga/couper/accesscontrol"
+	"github.com/dgrijalva/jwt-go/v4"
+
+	"github.com/avenga/couper/accesscontrol/jwk"
 	"github.com/avenga/couper/internal/test"
 )
 
@@ -23,7 +25,7 @@ func Test_JWKS(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			_, err := ac.NewJWKS(tt.url, "", nil, nil)
+			_, err := jwk.NewJWKS(tt.url, "", nil, nil)
 			if err == nil && tt.error != "" {
 				subT.Errorf("Missing error:\n\tWant: %v\n\tGot:  %v", tt.error, nil)
 			}
@@ -46,9 +48,9 @@ func Test_JWKS_Load(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			jwks, err := ac.NewJWKS("file:"+tt.file, "", nil, nil)
+			jwks, err := jwk.NewJWKS("file:"+tt.file, "", nil, nil)
 			helper.Must(err)
-			_, err = jwks.Load()
+			_, err = jwks.Data("")
 			if err != nil && tt.expParsed {
 				subT.Error("no jwks parsed")
 			}
@@ -59,6 +61,38 @@ func Test_JWKS_Load(t *testing.T) {
 	}
 }
 
+func Test_JWKS_GetSigKeyForToken(t *testing.T) {
+	tests := []struct {
+		name     string
+		file     string
+		kid      interface{}
+		alg      interface{}
+		expFound bool
+	}{
+		{"non-empty kid, non-empty alg", "testdata/jwks.json", "kid1", "RS256", true},
+		{"nil kid, non-empty alg", "testdata/jwks_no_kid.json", nil, "RS256", true},
+		{"non-empty kid, nil alg", "testdata/jwks_no_alg.json", "kid1", nil, false},
+		{"nil kid, nil alg", "testdata/jwks_no_kid_no_alg.json", nil, nil, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(subT *testing.T) {
+			helper := test.New(subT)
+			jwks, err := jwk.NewJWKS("file:"+tt.file, "", nil, nil)
+			helper.Must(err)
+			_, err = jwks.Data("")
+			helper.Must(err)
+			token := &jwt.Token{Header: map[string]interface{}{"kid": tt.kid, "alg": tt.alg}}
+			jwk, err := jwks.GetSigKeyForToken(token)
+			if jwk == nil && tt.expFound {
+				subT.Errorf("no jwk found, %v", err)
+			}
+			if jwk != nil && !tt.expFound {
+				subT.Error("no jwk expected")
+			}
+		})
+	}
+}
+
 func Test_JWKS_GetKey(t *testing.T) {
 	tests := []struct {
 		name     string
@@ -80,8 +114,8 @@ func Test_JWKS_GetKey(t *testing.T) {
 		{"no_use: key for empty use", "testdata/jwks_no_use.json", "kid1", "RS256", "", true}, // not useful: we always call with "sig"
 		{"no_kid: key for empty kid", "testdata/jwks_no_kid.json", "", "RS256", "sig", true},  // or better nil kid?
 		{"no_kid: no key for kid", "testdata/jwks_no_kid.json", "kid1", "RS256", "sig", false},
-		{"no_alg: no key for empty alg", "testdata/jwks_no_alg.json", "kid1", "", "sig", true}, // or better nil alg?
-		{"no_alg: no key for alg", "testdata/jwks_no_alg.json", "kid1", "RS256", "sig", false},
+		{"no_alg: key for empty alg", "testdata/jwks_no_alg.json", "kid1", "", "sig", true}, // or better nil alg?
+		{"no_alg: key for alg", "testdata/jwks_no_alg.json", "kid1", "RS256", "sig", true},
 		{"no_kid_no_alg: key for empty kid, empty alg, sig", "testdata/jwks_no_kid_no_alg.json", "", "", "sig", true},
 		{"no_kid_no_alg: key for kid, alg, sig", "testdata/jwks_no_kid_no_alg.json", "kid", "RS256", "sig", false},
 		{"no_kid_no_alg_no_use: key for empty kid, empty alg, sig", "testdata/jwks_no_kid_no_alg_no_use.json", "", "", "sig", false},
@@ -95,9 +129,9 @@ 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 := ac.NewJWKS("file:"+tt.file, "", nil, nil)
+			jwks, err := jwk.NewJWKS("file:"+tt.file, "", nil, nil)
 			helper.Must(err)
-			_, err = jwks.Load()
+			_, err = jwks.Data("")
 			helper.Must(err)
 			jwk, err := jwks.GetKey(tt.kid, tt.alg, tt.use)
 			if jwk == nil && tt.expFound {
@@ -116,7 +150,7 @@ func Test_JWKS_LoadSynced(t *testing.T) {
 	memQuitCh := make(chan struct{})
 	defer close(memQuitCh)
 
-	jwks, err := ac.NewJWKS("file:testdata/jwks.json", "", nil, nil)
+	jwks, err := jwk.NewJWKS("file:testdata/jwks.json", "", nil, nil)
 	helper.Must(err)
 
 	wg := sync.WaitGroup{}
diff --git a/eval/lib/oauth2_test.go b/eval/lib/oauth2_test.go
index 22668242..0ba3c2f2 100644
--- a/eval/lib/oauth2_test.go
+++ b/eval/lib/oauth2_test.go
@@ -12,7 +12,7 @@ import (
 	"github.com/hashicorp/hcl/v2/hclsyntax"
 	"github.com/zclconf/go-cty/cty"
 
-	"github.com/avenga/couper/cache"
+	"github.com/avenga/couper/accesscontrol/jwk"
 	"github.com/avenga/couper/config"
 	hclbody "github.com/avenga/couper/config/body"
 	"github.com/avenga/couper/config/configload"
@@ -25,6 +25,7 @@ import (
 )
 
 func TestNewOAuthAuthorizationUrlFunction(t *testing.T) {
+	helper := test.New(t)
 
 	expFn := func(exp string) hcl.Expression {
 		e, diags := hclsyntax.ParseExpression([]byte(exp), "", hcl.InitialPos)
@@ -36,44 +37,54 @@ func TestNewOAuthAuthorizationUrlFunction(t *testing.T) {
 
 	var origin *httptest.Server
 	origin = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
-		conf := &oidc.OpenidConfiguration{
-			AuthorizationEndpoint:         origin.URL + "/auth",
-			Issuer:                        "me",
-			TokenEndpoint:                 origin.URL + "/token",
-			UserinfoEndpoint:              origin.URL + "/userinfo",
-			CodeChallengeMethodsSupported: []string{"S256"},
+		var conf interface{}
+		if req.URL.Path == "/.well-known/openid-configuration" {
+			conf = &oidc.OpenidConfiguration{
+				AuthorizationEndpoint:         origin.URL + "/auth",
+				CodeChallengeMethodsSupported: []string{config.CcmS256},
+				Issuer:                        "thatsme",
+				JwksUri:                       origin.URL + "/jwks",
+				TokenEndpoint:                 origin.URL + "/token",
+				UserinfoEndpoint:              origin.URL + "/userinfo",
+			}
+		} else if req.URL.Path == "/jwks" {
+			conf = jwk.JWKSData{}
 		}
-		b, _ := json.Marshal(conf)
-		_, _ = rw.Write(b)
+		b, err := json.Marshal(conf)
+		helper.Must(err)
+		_, err = rw.Write(b)
+		helper.Must(err)
 	}))
 
 	backend := configload.NewBackend("origin", test.NewRemainContext("origin", origin.URL))
 
-	memQuitCh := make(chan struct{})
-	defer close(memQuitCh)
 	log, _ := test.NewLogger()
 	logger := log.WithContext(context.Background())
-	memStore := cache.New(logger, memQuitCh)
 
 	tests := []struct {
 		name         string
 		oauth2Config *config.OIDC
 		want         string
-	}{{name: "redirect_uri with client request",
-		oauth2Config: &config.OIDC{
-			Name: "auth-ref",
-			Remain: hclbody.New(&hcl.BodyContent{
-				Attributes: map[string]*hcl.Attribute{
-					lib.RedirectURI: {
-						Name: lib.RedirectURI,
-						Expr: expFn("request.headers.x-want"),
-					},
-				}})},
-		want: "https://couper.io/cb",
-	},
-		{name: "redirect_uri with backend_requests", // works since client and backend request are the same at response obj
+	}{
+		{
+			name: "redirect_uri with client request",
+			oauth2Config: &config.OIDC{
+				Name:             "auth-ref",
+				ConfigurationURL: origin.URL + "/.well-known/openid-configuration",
+				Remain: hclbody.New(&hcl.BodyContent{
+					Attributes: map[string]*hcl.Attribute{
+						lib.RedirectURI: {
+							Name: lib.RedirectURI,
+							Expr: expFn("request.headers.x-want"),
+						},
+					}})},
+			want: "https://couper.io/cb",
+		},
+		{
+			name: "redirect_uri with backend_requests", // works since client and backend request are the same at response obj
 			oauth2Config: &config.OIDC{
-				Name: "auth-ref",
+				Name:             "auth-ref",
+				ConfigurationURL: origin.URL + "/.well-known/openid-configuration",
 				Remain: hclbody.New(&hcl.BodyContent{
 					Attributes: map[string]*hcl.Attribute{
 						lib.RedirectURI: {
@@ -83,9 +94,11 @@ func TestNewOAuthAuthorizationUrlFunction(t *testing.T) {
 					}})},
 			want: "https://couper.io/cb",
 		},
-		{name: "redirect_uri with backend_responses",
+		{
+			name: "redirect_uri with backend_responses",
 			oauth2Config: &config.OIDC{
-				Name: "auth-ref",
+				Name:             "auth-ref",
+				ConfigurationURL: origin.URL + "/.well-known/openid-configuration",
 				Remain: hclbody.New(&hcl.BodyContent{
 					Attributes: map[string]*hcl.Attribute{
 						lib.RedirectURI: {
@@ -116,9 +129,8 @@ func TestNewOAuthAuthorizationUrlFunction(t *testing.T) {
 			tc := &transport.Config{}
 			conf, err := oidc.NewConfig(tt.oauth2Config, transport.NewBackend(backend.Config,
 				tc.With("http", "couper.io", "couper.io", ""),
-				&transport.BackendOptions{}, logger), memStore)
+				&transport.BackendOptions{}, logger))
 			helper.Must(err)
-			conf.ConfigurationURL = origin.URL
 
 			ctx := eval.NewContext(nil, &config.Defaults{}).
 				WithOidcConfig(oidc.Configs{conf.Name: conf}).
diff --git a/oauth2/oidc/config_test.go b/oauth2/oidc/config_test.go
index b20a7f5f..e7c775d5 100644
--- a/oauth2/oidc/config_test.go
+++ b/oauth2/oidc/config_test.go
@@ -8,7 +8,7 @@ import (
 	"sync"
 	"testing"
 
-	"github.com/avenga/couper/cache"
+	"github.com/avenga/couper/accesscontrol/jwk"
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/configload"
 	"github.com/avenga/couper/handler/transport"
@@ -16,7 +16,7 @@ import (
 	"github.com/avenga/couper/oauth2/oidc"
 )
 
-func TestConfig_getFreshIfExpiredSynced(t *testing.T) {
+func TestConfig_Synced(t *testing.T) {
 	helper := test.New(t)
 
 	log, _ := test.NewLogger()
@@ -24,12 +24,18 @@ func TestConfig_getFreshIfExpiredSynced(t *testing.T) {
 
 	var origin *httptest.Server
 	origin = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
-		conf := &oidc.OpenidConfiguration{
-			AuthorizationEndpoint:         origin.URL + "/auth",
-			Issuer:                        "thatsme",
-			TokenEndpoint:                 origin.URL + "/token",
-			UserinfoEndpoint:              origin.URL + "/userinfo",
-			CodeChallengeMethodsSupported: []string{config.CcmS256},
+		var conf interface{}
+		if req.URL.Path == "/.well-known/openid-configuration" {
+			conf = &oidc.OpenidConfiguration{
+				AuthorizationEndpoint:         origin.URL + "/auth",
+				CodeChallengeMethodsSupported: []string{config.CcmS256},
+				Issuer:                        "thatsme",
+				JwksUri:                       origin.URL + "/jwks",
+				TokenEndpoint:                 origin.URL + "/token",
+				UserinfoEndpoint:              origin.URL + "/userinfo",
+			}
+		} else if req.URL.Path == "/jwks" {
+			conf = jwk.JWKSData{}
 		}
 
 		b, err := json.Marshal(conf)
@@ -39,12 +45,8 @@ func TestConfig_getFreshIfExpiredSynced(t *testing.T) {
 	}))
 	defer origin.Close()
 
-	memQuitCh := make(chan struct{})
-	defer close(memQuitCh)
-	memStore := cache.New(logger, memQuitCh)
-
 	be := transport.NewBackend(configload.EmptyBody(), &transport.Config{}, nil, logger)
-	o, err := oidc.NewConfig(&config.OIDC{ConfigurationURL: origin.URL}, be, memStore)
+	o, err := oidc.NewConfig(&config.OIDC{ConfigurationURL: origin.URL + "/.well-known/openid-configuration"}, be)
 	helper.Must(err)
 
 	wg := sync.WaitGroup{}
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 88f71c6d..78185097 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -3138,10 +3138,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: missing \"kid\" in JOSE header"},
+		{"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: error loading JWKS: status code 404"},
+		{"local RSA JWKS not found", "/jwks/rsa/not_found", http.Header{"Authorization": []string{"Bearer " + rsaToken}}, "", http.StatusForbidden, "", "access control error: JWKS_not_found: error loading synced JSON: 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, "", ""},
@@ -4106,6 +4106,7 @@ func TestOIDCPKCEFunctions(t *testing.T) {
 			"issuer": "https://authorization.server",
 			"authorization_endpoint": "https://authorization.server/oauth2/authorize",
 			"token_endpoint": "http://` + req.Host + `/token",
+			"jwks_uri": "http://` + req.Host + `/jwks",
 			"userinfo_endpoint": "http://` + req.Host + `/userinfo"
 			}`)
 			_, werr := rw.Write(body)
@@ -4176,6 +4177,7 @@ func TestOIDCNonceFunctions(t *testing.T) {
 			"issuer": "https://authorization.server",
 			"authorization_endpoint": "https://authorization.server/oauth2/authorize",
 			"token_endpoint": "http://` + req.Host + `/token",
+			"jwks_uri": "http://` + req.Host + `/jwks",
 			"userinfo_endpoint": "http://` + req.Host + `/userinfo"
 			}`)
 			_, werr := rw.Write(body)
@@ -4240,6 +4242,7 @@ func TestOIDCDefaultPKCEFunctions(t *testing.T) {
 			"issuer": "https://authorization.server",
 			"authorization_endpoint": "https://authorization.server/oauth2/authorize",
 			"token_endpoint": "http://` + req.Host + `/token",
+			"jwks_uri": "http://` + req.Host + `/jwks",
 			"userinfo_endpoint": "http://` + req.Host + `/userinfo",
 			"code_challenge_methods_supported": ["S256"]
 			}`)
@@ -4305,6 +4308,7 @@ func TestOIDCDefaultNonceFunctions(t *testing.T) {
 			"issuer": "https://authorization.server",
 			"authorization_endpoint": "https://authorization.server/oauth2/authorize",
 			"token_endpoint": "http://` + req.Host + `/token",
+			"jwks_uri": "http://` + req.Host + `/jwks",
 			"userinfo_endpoint": "http://` + req.Host + `/userinfo"
 			}`)
 			_, werr := rw.Write(body)
diff --git a/server/http_oauth2_test.go b/server/http_oauth2_test.go
index 127d2f6e..3f101615 100644
--- a/server/http_oauth2_test.go
+++ b/server/http_oauth2_test.go
@@ -1,9 +1,11 @@
 package server_test
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -262,7 +264,18 @@ func TestOAuth2_AccessControl(t *testing.T) {
 				if !strings.HasSuffix(code, "-mn-id") {
 					mapClaims["nonce"] = nonce
 				}
-				idToken, _ := lib.CreateJWT("HS256", []byte("$e(rEt"), mapClaims, nil)
+				keyBytes, err := ioutil.ReadFile("testdata/integration/files/pkcs8.key")
+				helper.Must(err)
+				key, parseErr := jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
+				helper.Must(parseErr)
+				var kid string
+				if strings.HasSuffix(code, "-wkid-id") {
+					kid = "not-found"
+				} else {
+					kid = "rs256"
+				}
+				idToken, err := lib.CreateJWT("RS256", key, mapClaims, map[string]interface{}{"kid": kid})
+				helper.Must(err)
 				idTokenToAdd = `"id_token":"` + idToken + `",
 				`
 			}
@@ -284,11 +297,20 @@ func TestOAuth2_AccessControl(t *testing.T) {
 			_, werr := rw.Write(body)
 			helper.Must(werr)
 
+			return
+		} else if req.URL.Path == "/jwks" {
+			jsonBytes, rerr := ioutil.ReadFile("testdata/integration/files/jwks.json")
+			helper.Must(rerr)
+			b := bytes.NewBuffer(jsonBytes)
+			_, werr := b.WriteTo(rw)
+			helper.Must(werr)
+
 			return
 		} else if req.URL.Path == "/.well-known/openid-configuration" {
 			body := []byte(`{
 			"issuer": "https://authorization.server",
 			"authorization_endpoint": "https://authorization.server/oauth2/authorize",
+			"jwks_uri": "http://` + req.Host + `/jwks",
 			"token_endpoint": "http://` + req.Host + `/token",
 			"userinfo_endpoint": "http://` + req.Host + `/userinfo"
 			}`)
@@ -340,6 +362,7 @@ func TestOAuth2_AccessControl(t *testing.T) {
 		{"code, missing aud claim", "07_couper.hcl", http.MethodGet, "/cb?code=qeuboub-maud-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: token response validation error: missing aud claim in ID token, claims='jwt.MapClaims{\"azp\":\"foo\", \"exp\":4e+09, \"iat\":1000, \"iss\":\"https://authorization.server\", \"nonce\":\"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\", \"sub\":\"myself\"}'"},
 		{"code, null aud claim", "07_couper.hcl", http.MethodGet, "/cb?code=qeuboub-naud-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: token response validation error: aud claim in ID token must not be null"},
 		{"code, wrong aud claim", "07_couper.hcl", http.MethodGet, "/cb?code=qeuboub-waud-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: token response validation error: token audience is invalid: 'foo' wasn't found in aud claim"},
+		{"code, wrong kid", "07_couper.hcl", http.MethodGet, "/cb?code=qeuboub-wkid-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: token response validation error: token is unverifiable: Keyfunc returned an error"},
 		{"code; client_secret_basic; PKCE", "04_couper.hcl", http.MethodGet, "/cb?code=qeuboub", http.Header{"Cookie": []string{"pkcecv=qerbnr"}}, http.StatusOK, "code=qeuboub&code_verifier=qerbnr&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcb", "Basic Zm9vOmV0YmluYnA0aW4=", ""},
 		{"code; client_secret_post", "05_couper.hcl", http.MethodGet, "/cb?code=qeuboub", http.Header{"Cookie": []string{"pkcecv=qerbnr"}}, http.StatusOK, "client_id=foo&client_secret=etbinbp4in&code=qeuboub&code_verifier=qerbnr&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcb", "", ""},
 		{"code, state param", "06_couper.hcl", http.MethodGet, "/cb?code=qeuboub&state=" + state, http.Header{"Cookie": []string{"st=" + st}}, http.StatusOK, "code=qeuboub&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcb", "Basic Zm9vOmV0YmluYnA0aW4=", ""},
@@ -423,7 +446,13 @@ func TestOAuth2_AC_Backend(t *testing.T) {
 				"exp": 4000000000,
 				"iat": 1000,
 			}
-			idToken, _ := lib.CreateJWT("HS256", []byte("$e(rEt"), mapClaims, nil)
+			keyBytes, err := ioutil.ReadFile("testdata/integration/files/pkcs8.key")
+			helper.Must(err)
+			key, parseErr := jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
+			helper.Must(parseErr)
+			idToken, err := lib.CreateJWT("RS256", key, mapClaims, map[string]interface{}{"kid": "rs256"})
+			helper.Must(err)
+			// idToken, _ := lib.CreateJWT("HS256", []byte("$e(rEt"), mapClaims, nil)
 			idTokenToAdd := `"id_token":"` + idToken + `",
 			`
 
@@ -441,12 +470,21 @@ func TestOAuth2_AC_Backend(t *testing.T) {
 			_, werr := rw.Write(body)
 			helper.Must(werr)
 
+			return
+		} else if req.URL.Path == "/jwks" {
+			jsonBytes, rerr := ioutil.ReadFile("testdata/integration/files/jwks.json")
+			helper.Must(rerr)
+			b := bytes.NewBuffer(jsonBytes)
+			_, werr := b.WriteTo(rw)
+			helper.Must(werr)
+
 			return
 		} else if req.URL.Path == "/.well-known/openid-configuration" {
 			body := []byte(`{
 			"issuer": "https://authorization.server",
 			"authorization_endpoint": "https://authorization.server/oauth2/authorize",
 			"token_endpoint": "http://` + req.Host + `/token",
+			"jwks_uri": "http://` + req.Host + `/jwks",
 			"userinfo_endpoint": "http://` + req.Host + `/userinfo"
 			}`)
 			_, werr := rw.Write(body)