Repository: avenga/couper
Pull request #247 (2021-05-26 07:20:56)

OAuth2 Authorization Code Grant Flow

johakoch: Im Moment ist es so implementiert, dass erwartet wird, dass Code-Verifier oder CSRF-Token als solche an den Browser gehen: Im Request an den Redirekt-Endpunkt müssen sie aus dem Request ermittelbar sein (z.B. im Cookie). Dafür gibt es die Konfigurations-Attribute `code_verifier_value` und `csrf_token_value`:
```hcl
oauth2 "my-ac" {
  ...
  code_verifier_value = request.cookies.cv
#  csrf_token_value = request.cookies.ct
  ...
}
```
Ohne einen zentralen Store ist es dann auch wahrscheinlich, dass Access-/Id-/Refresh-Tokens als solche (als Cookie) im Browser gespeichert werden. Hätte man einen (von verschiedenen Couper-Instanzen verwendeten) zentralen Store, könnte man die Tokens selbst im Store speichern und nur eine Referenz darauf an den Browser gehen. Das würde ggfs. die Sicherheit noch erhöhen.
Ebenso könnten dann Code-Verifier/CSRF-Token selbst im Store verbleiben und nur Referenzen darauf an den Browser gehen.
Verbaut man sich nun durch die genannten Konfigurations-Attribute eine mögliche Erweiterung durch einen zentralen Store? Vermutlich nicht: `code_verifier_value`/`csrf_token_value` müssten dann nur anders interpretiert werden, nämlich als Referenzen auf die eigentlichen Werte. Welche Variante gewünscht ist, ließe sich sicherlich konfigurativ ausdrücken.
filex: 
> Verbaut man sich nun durch die genannten Konfigurations-Attribute eine mögliche Erweiterung durch einen zentralen Store? Vermutlich nicht: `code_verifier_value`/`csrf_token_value` müssten dann nur anders interpretiert werden, nämlich als Referenzen auf die eigentlichen Werte. Welche Variante gewünscht ist, ließe sich sicherlich konfigurativ ausdrücken.

The current idea is, that `code_verifier_value` is defined as an arbitrary expression that usually involves cookies. if we would incorporate a KV interface, we could provide a lookup function as a simple solution. That would still fit the expression approach.

But I would rather make that token store a built-in feature that is configured outside of the AC. We could then reference it with a new set of config attributes that are mutually exclusive with `code_verifier_value`.

For the task here, I agree that we can proceed with the value-approach.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index be7ebf90..71df465e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
 
 Unreleased changes are available as `avenga/couper:edge` container.
 
+* **Added**
+  * OAuth2 Authorization Code Grant Flow: `beta_oauth2 {}` block; `beta_oauth_authorization_url()`, `beta_oauth_code_verifier()` and `beta_oauth_csrf_token()` functions ([#247](https://github.com/avenga/couper/pull/247))
 ---
 
 ## [1.3.1](https://github.com/avenga/couper/compare/1.3...1.3.1)
diff --git a/accesscontrol/oauth2.go b/accesscontrol/oauth2.go
new file mode 100644
index 00000000..4f2331dc
--- /dev/null
+++ b/accesscontrol/oauth2.go
@@ -0,0 +1,328 @@
+package accesscontrol
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/dgrijalva/jwt-go/v4"
+
+	"github.com/avenga/couper/config"
+	"github.com/avenga/couper/config/request"
+	"github.com/avenga/couper/errors"
+	"github.com/avenga/couper/eval"
+	"github.com/avenga/couper/eval/lib"
+	"github.com/avenga/couper/handler/transport"
+	"github.com/avenga/couper/internal/seetie"
+)
+
+var _ AccessControl = &OAuth2Callback{}
+
+type OAuth2Callback struct {
+	config    *config.OAuth2AC
+	oauth2    *transport.OAuth2
+	jwtParser *jwt.Parser
+}
+
+// NewOAuth2Callback creates a new AC-OAuth2 object
+func NewOAuth2Callback(conf *config.OAuth2AC, oauth2 *transport.OAuth2) (*OAuth2Callback, error) {
+	confErr := errors.Configuration.Label(conf.Name)
+
+	const grantType = "authorization_code"
+	if conf.GrantType != grantType {
+		return nil, confErr.Messagef("grant_type %s not supported", conf.GrantType)
+	}
+	if conf.Pkce == nil && conf.Csrf == nil {
+		return nil, confErr.Message("CSRF protection not configured")
+	}
+	if conf.Csrf != nil {
+		if conf.Csrf.TokenParam != "state" && conf.Csrf.TokenParam != "nonce" {
+			return nil, confErr.Messagef("csrf_token_param %s not supported", conf.Csrf.TokenParam)
+		}
+		content, _, diags := conf.Csrf.HCLBody().PartialContent(conf.Csrf.Schema(true))
+		if diags.HasErrors() {
+			return nil, errors.Evaluation.With(diags)
+		}
+		conf.Csrf.Content = content
+	}
+	if conf.Pkce != nil {
+		if conf.Pkce.CodeChallengeMethod != lib.CcmPlain && conf.Pkce.CodeChallengeMethod != lib.CcmS256 {
+			return nil, confErr.Messagef("code_challenge_method %s not supported", conf.Pkce.CodeChallengeMethod)
+		}
+		content, _, diags := conf.Pkce.HCLBody().PartialContent(conf.Pkce.Schema(true))
+		if diags.HasErrors() {
+			return nil, errors.Evaluation.With(diags)
+		}
+		conf.Pkce.Content = content
+	}
+
+	options := []jwt.ParserOption{
+		// jwt.WithValidMethods([]string{algo.String()}),
+		jwt.WithLeeway(time.Second),
+		// 2. The Issuer Identifier for the OpenID Provider (which is typically
+		//    obtained during Discovery) MUST exactly match the value of the iss
+		//    (issuer) Claim.
+		jwt.WithIssuer(conf.Issuer),
+		// 3. The Client MUST validate that the aud (audience) Claim contains its
+		//    client_id value registered at the Issuer identified by the iss
+		//    (issuer) Claim as an audience. The aud (audience) Claim MAY contain
+		//    an array with more than one element. The ID Token MUST be rejected if
+		//    the ID Token does not list the Client as a valid audience, or if it
+		//    contains additional audiences not trusted by the Client.
+		jwt.WithAudience(conf.ClientID),
+	}
+	jwtParser := jwt.NewParser(options...)
+
+	return &OAuth2Callback{
+		config:    conf,
+		jwtParser: jwtParser,
+		oauth2:    oauth2,
+	}, nil
+}
+
+// Validate implements the AccessControl interface
+func (oa *OAuth2Callback) Validate(req *http.Request) error {
+	if req.Method != http.MethodGet {
+		return errors.Oauth2.Messagef("wrong method: %s", req.Method)
+	}
+
+	query := req.URL.Query()
+	code := query.Get("code")
+	if code == "" {
+		return errors.Oauth2.Messagef("missing code query parameter; query=%q", req.URL.RawQuery)
+	}
+
+	requestConfig, err := oa.oauth2.GetRequestConfig(req)
+	if err != nil {
+		return errors.Oauth2.With(err)
+	}
+
+	evalContext, _ := req.Context().Value(eval.ContextType).(*eval.Context)
+
+	if oa.config.Pkce != nil {
+		v, _ := oa.config.Pkce.Content.Attributes["code_verifier_value"]
+		ctyVal, _ := v.Expr.Value(evalContext.HCLContext())
+		codeVerifierValue := strings.TrimSpace(seetie.ValueToString(ctyVal))
+		if codeVerifierValue == "" {
+			return errors.Oauth2.Message("Empty PKCE code_verifier_value")
+		}
+		requestConfig.CodeVerifier = &codeVerifierValue
+	}
+
+	var csrfToken, csrfTokenValue string
+	if oa.config.Csrf != nil {
+		v, _ := oa.config.Csrf.Content.Attributes["token_value"]
+		ctyVal, _ := v.Expr.Value(evalContext.HCLContext())
+		csrfTokenValue = strings.TrimSpace(seetie.ValueToString(ctyVal))
+		if csrfTokenValue == "" {
+			return errors.Oauth2.Message("Empty CSRF token_value")
+		}
+		csrfToken = Base64urlSha256(csrfTokenValue)
+
+		// validate state param value against CSRF token
+		if oa.config.Csrf.TokenParam == "state" {
+			csrfTokenFromParam := query.Get(oa.config.Csrf.TokenParam)
+			if csrfTokenFromParam == "" {
+				return errors.Oauth2.Messagef("missing state query parameter; query=%q", req.URL.RawQuery)
+			}
+
+			if csrfToken != csrfTokenFromParam {
+				return errors.Oauth2.Messagef("CSRF token mismatch: %q (from query param) vs. %q (s256: %q)", csrfTokenFromParam, csrfTokenValue, csrfToken)
+			}
+		}
+	}
+
+	requestConfig.Code = &code
+	requestConfig.RedirectURI = oa.config.RedirectURI
+
+	tokenResponse, err := oa.oauth2.RequestToken(req.Context(), requestConfig)
+	if err != nil {
+		return errors.Oauth2.Message("requesting token failed").With(err)
+	}
+
+	tokenData, accessToken, err := transport.ParseAccessToken(tokenResponse)
+	if err != nil {
+		return errors.Oauth2.Messagef("parsing token response JSON failed, response=%q", string(tokenResponse)).With(err)
+	}
+
+	ctx := req.Context()
+	if idTokenString, ok := tokenData["id_token"].(string); ok {
+		idToken, _, err := oa.jwtParser.ParseUnverified(idTokenString, jwt.MapClaims{})
+		if err != nil {
+			return err
+		}
+
+		// 2.  ID Token
+		// iss
+		// 		REQUIRED.
+		// aud
+		// 		REQUIRED.
+		// 3.1.3.7.  ID Token Validation
+		// 3. The Client MUST validate that the aud (audience) Claim contains
+		//    its client_id value registered at the Issuer identified by the
+		//    iss (issuer) Claim as an audience. The aud (audience) Claim MAY
+		//    contain an array with more than one element. The ID Token MUST
+		//    be rejected if the ID Token does not list the Client as a valid
+		//    audience, or if it contains additional audiences not trusted by
+		//    the Client.
+		if err := idToken.Claims.Valid(oa.jwtParser.ValidationHelper); err != nil {
+			return err
+		}
+
+		idtc, userinfo, err := oa.validateIdTokenClaims(ctx, idToken.Claims, csrfToken, csrfTokenValue, accessToken)
+		if err != nil {
+			return err
+		}
+
+		tokenData["id_token_claims"] = idtc
+		tokenData["userinfo"] = userinfo
+	}
+
+	acMap, ok := ctx.Value(request.AccessControls).(map[string]interface{})
+	if !ok {
+		acMap = make(map[string]interface{})
+	}
+	acMap[oa.config.Name] = tokenData
+	ctx = context.WithValue(ctx, request.AccessControls, acMap)
+	*req = *req.WithContext(ctx)
+
+	return nil
+}
+
+func (oa *OAuth2Callback) validateIdTokenClaims(ctx context.Context, claims jwt.Claims, csrfToken, csrfTokenValue string, accessToken string) (map[string]interface{}, map[string]interface{}, error) {
+	var idTokenClaims jwt.MapClaims
+	if tc, ok := claims.(jwt.MapClaims); ok {
+		idTokenClaims = tc
+	}
+
+	// 2.  ID Token
+	// exp
+	// 		REQUIRED.
+	if _, expExists := idTokenClaims["exp"]; !expExists {
+		return nil, nil, errors.Oauth2.Messagef("missing exp claim in ID token, claims='%#v'", idTokenClaims)
+	}
+	// iat
+	// 		REQUIRED.
+	if _, iatExists := idTokenClaims["iat"]; !iatExists {
+		return nil, nil, errors.Oauth2.Messagef("missing iat claim in ID token, claims='%#v'", idTokenClaims)
+	}
+
+	// 3.1.3.7.  ID Token Validation
+	// 4. If the ID Token contains multiple audiences, the Client SHOULD verify
+	//    that an azp Claim is present.
+	azp, azpExists := idTokenClaims["azp"]
+	if auds, audsOK := idTokenClaims["aud"].([]interface{}); audsOK && len(auds) > 1 && !azpExists {
+		return nil, nil, errors.Oauth2.Messagef("missing azp claim in ID token, claims='%#v'", idTokenClaims)
+	}
+	// 5. If an azp (authorized party) Claim is present, the Client SHOULD
+	//    verify that its client_id is the Claim Value.
+	if azpExists && azp != oa.config.ClientID {
+		return nil, nil, errors.Oauth2.Messagef("azp claim / client ID mismatch, azp = %q, client ID = %q", azp, oa.config.ClientID)
+	}
+
+	// validate nonce claim value against CSRF token
+	if oa.config.Csrf != nil && oa.config.Csrf.TokenParam == "nonce" {
+		// 11. If a nonce value was sent in the Authentication Request, a nonce
+		//     Claim MUST be present and its value checked to verify that it is the
+		//     same value as the one that was sent in the Authentication Request.
+		//     The Client SHOULD check the nonce value for replay attacks. The
+		//     precise method for detecting replay attacks is Client specific.
+		var nonce string
+		if n, ok := idTokenClaims["nonce"].(string); ok {
+			nonce = n
+		} else {
+			return nil, nil, errors.Oauth2.Messagef("missing nonce claim in ID token, claims='%#v'", idTokenClaims)
+		}
+
+		if csrfToken != nonce {
+			return nil, nil, errors.Oauth2.Messagef("CSRF token mismatch: %q (from nonce claim) vs. %q (s256: %q)", nonce, csrfTokenValue, csrfToken)
+		}
+	}
+
+	// 2.  ID Token
+	// sub
+	// 		REQUIRED.
+	var subIdtoken string
+	if s, ok := idTokenClaims["sub"].(string); ok {
+		subIdtoken = s
+	} else {
+		return nil, nil, errors.Oauth2.Messagef("missing sub claim in ID token, claims='%#v'", idTokenClaims)
+	}
+
+	userinfoResponse, err := oa.requestUserinfo(ctx, accessToken)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	userinfoResponseString := string(userinfoResponse)
+	var userinfoData map[string]interface{}
+	err = json.Unmarshal(userinfoResponse, &userinfoData)
+	if err != nil {
+		return nil, nil, errors.Oauth2.Messagef("parsing userinfo response JSON failed, response=%q", userinfoResponseString).With(err)
+	}
+
+	var subUserinfo string
+	if s, ok := userinfoData["sub"].(string); ok {
+		subUserinfo = s
+	} else {
+		return nil, nil, errors.Oauth2.Messagef("missing sub property in userinfo response, response=%q", userinfoResponseString)
+	}
+
+	if subIdtoken != subUserinfo {
+		return nil, nil, errors.Oauth2.Messagef("subject mismatch, in ID token %q, in userinfo response %q", subIdtoken, subUserinfo)
+	}
+
+	return idTokenClaims, userinfoData, nil
+}
+
+func (oa *OAuth2Callback) requestUserinfo(ctx context.Context, accessToken string) ([]byte, error) {
+	userinfoReq, err := oa.newUserinfoRequest(ctx, accessToken)
+	if err != nil {
+		return nil, err
+	}
+
+	userinfoRes, err := oa.oauth2.Backend.RoundTrip(userinfoReq)
+	if err != nil {
+		return nil, err
+	}
+
+	userinfoResBytes, err := ioutil.ReadAll(userinfoRes.Body)
+	if err != nil {
+		return nil, errors.Backend.Label(oa.config.Reference()).Message("userinfo request read error").With(err)
+	}
+
+	if userinfoRes.StatusCode != http.StatusOK {
+		return nil, errors.Backend.Label(oa.config.Reference()).Messagef("userinfo request failed, response=%q", string(userinfoResBytes))
+	}
+
+	return userinfoResBytes, nil
+}
+
+func (oa *OAuth2Callback) newUserinfoRequest(ctx context.Context, accessToken string) (*http.Request, error) {
+	if oa.config.UserinfoEndpoint == "" {
+		return nil, errors.Oauth2.Message("missing userinfo_endpoint in config")
+	}
+
+	// url will be configured via backend roundtrip
+	outreq, err := http.NewRequest(http.MethodGet, oa.config.UserinfoEndpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	outreq.Header.Set("Authorization", "Bearer "+accessToken)
+
+	outCtx := context.WithValue(ctx, request.URLAttribute, oa.config.UserinfoEndpoint)
+
+	return outreq.WithContext(outCtx), nil
+}
+
+func Base64urlSha256(value string) string {
+	h := sha256.New()
+	h.Write([]byte(value))
+	return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
+}
diff --git a/config/ac_oauth2.go b/config/ac_oauth2.go
new file mode 100644
index 00000000..931f805b
--- /dev/null
+++ b/config/ac_oauth2.go
@@ -0,0 +1,138 @@
+package config
+
+import (
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/gohcl"
+)
+
+var _ OAuth2 = &OAuth2AC{}
+
+// OAuth2AC represents the <OAuth2> access control object.
+type OAuth2AC struct {
+	AccessControlSetter
+	AuthorizationEndpoint   string   `hcl:"authorization_endpoint"`
+	BackendName             string   `hcl:"backend,optional"`
+	ClientID                string   `hcl:"client_id"`
+	ClientSecret            string   `hcl:"client_secret"`
+	Csrf                    *CSRF    `hcl:"csrf,block"`
+	GrantType               string   `hcl:"grant_type"`
+	Issuer                  string   `hcl:"issuer,optional"`
+	Name                    string   `hcl:"name,label"`
+	Pkce                    *PKCE    `hcl:"pkce,block"`
+	RedirectURI             *string  `hcl:"redirect_uri"`
+	Remain                  hcl.Body `hcl:",remain"`
+	Scope                   *string  `hcl:"scope,optional"`
+	TokenEndpoint           string   `hcl:"token_endpoint"`
+	TokenEndpointAuthMethod *string  `hcl:"token_endpoint_auth_method,optional"`
+	UserinfoEndpoint        string   `hcl:"userinfo_endpoint,optional"`
+	// internally used
+	Backend hcl.Body
+}
+
+func (oa OAuth2AC) HCLBody() hcl.Body {
+	return oa.Remain
+}
+
+func (oa OAuth2AC) Reference() string {
+	return oa.BackendName
+}
+
+func (oa OAuth2AC) Schema(inline bool) *hcl.BodySchema {
+	if !inline {
+		schema, _ := gohcl.ImpliedBodySchema(oa)
+		return schema
+	}
+
+	type Inline struct {
+		Backend *Backend `hcl:"backend,block"`
+	}
+
+	schema, _ := gohcl.ImpliedBodySchema(&Inline{})
+
+	// A backend reference is defined, backend block is not allowed.
+	if oa.BackendName != "" {
+		schema.Blocks = nil
+	}
+
+	return newBackendSchema(schema, oa.HCLBody())
+}
+
+func (oa OAuth2AC) GetClientID() string {
+	return oa.ClientID
+}
+
+func (oa OAuth2AC) GetClientSecret() string {
+	return oa.ClientSecret
+}
+
+func (oa OAuth2AC) GetGrantType() string {
+	return oa.GrantType
+}
+
+func (oa OAuth2AC) GetScope() *string {
+	return oa.Scope
+}
+
+func (oa OAuth2AC) GetTokenEndpoint() string {
+	return oa.TokenEndpoint
+}
+
+func (oa OAuth2AC) GetTokenEndpointAuthMethod() *string {
+	return oa.TokenEndpointAuthMethod
+}
+
+type PKCE struct {
+	CodeChallengeMethod string   `hcl:"code_challenge_method"`
+	Remain              hcl.Body `hcl:",remain"`
+	// internally used
+	Content *hcl.BodyContent
+}
+
+// HCLBody implements the <Body> interface.
+func (p PKCE) HCLBody() hcl.Body {
+	return p.Remain
+}
+
+// Schema implements the <Inline> interface.
+func (p PKCE) Schema(inline bool) *hcl.BodySchema {
+	if !inline {
+		schema, _ := gohcl.ImpliedBodySchema(p)
+		return schema
+	}
+
+	type Inline struct {
+		CodeVerifierValue string `hcl:"code_verifier_value"`
+	}
+
+	schema, _ := gohcl.ImpliedBodySchema(&Inline{})
+
+	return schema
+}
+
+type CSRF struct {
+	TokenParam string   `hcl:"token_param"`
+	Remain     hcl.Body `hcl:",remain"`
+	// internally used
+	Content *hcl.BodyContent
+}
+
+// HCLBody implements the <Body> interface.
+func (c CSRF) HCLBody() hcl.Body {
+	return c.Remain
+}
+
+// Schema implements the <Inline> interface.
+func (c CSRF) Schema(inline bool) *hcl.BodySchema {
+	if !inline {
+		schema, _ := gohcl.ImpliedBodySchema(c)
+		return schema
+	}
+
+	type Inline struct {
+		TokenValue string `hcl:"token_value"`
+	}
+
+	schema, _ := gohcl.ImpliedBodySchema(&Inline{})
+
+	return schema
+}
diff --git a/config/backend.go b/config/backend.go
index 2a5ea9fa..21a952e8 100644
--- a/config/backend.go
+++ b/config/backend.go
@@ -28,7 +28,7 @@ type Backend struct {
 	Timeout                string   `hcl:"timeout,optional"`
 
 	// explicit configuration on load
-	OAuth2 *OAuth2
+	OAuth2 *OAuth2ReqAuth
 }
 
 // HCLBody implements the <Inline> interface.
diff --git a/config/configload/load.go b/config/configload/load.go
index 67bfc34a..8c160ec4 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -139,6 +139,18 @@ func LoadConfig(body hcl.Body, src []byte, filename string) (*config.Couper, err
 				return nil, diags
 			}
 
+			for _, oauth2Config := range couperConfig.Definitions.OAuth2AC {
+				err := uniqueAttributeKey(oauth2Config.Remain)
+				if err != nil {
+					return nil, err
+				}
+
+				oauth2Config.Backend, err = newBackend(definedBackends, oauth2Config)
+				if err != nil {
+					return nil, err
+				}
+			}
+
 			// access control - error_handler
 			var acErrorHandler []AccessControlSetter
 			for _, acConfig := range couperConfig.Definitions.BasicAuth {
@@ -150,6 +162,9 @@ func LoadConfig(body hcl.Body, src []byte, filename string) (*config.Couper, err
 			for _, acConfig := range couperConfig.Definitions.SAML {
 				acErrorHandler = append(acErrorHandler, acConfig)
 			}
+			for _, acConfig := range couperConfig.Definitions.OAuth2AC {
+				acErrorHandler = append(acErrorHandler, acConfig)
+			}
 
 			for _, ac := range acErrorHandler {
 				acBody, ok := ac.(config.Body)
@@ -236,6 +251,7 @@ func LoadConfig(body hcl.Body, src []byte, filename string) (*config.Couper, err
 
 	couperConfig.Context = evalContext.
 		WithJWTProfiles(couperConfig.Definitions.JWTSigningProfile).
+		WithOAuth2(couperConfig.Definitions.OAuth2AC).
 		WithSAML(couperConfig.Definitions.SAML)
 
 	// Read per server block and merge backend settings which results in a final server configuration.
@@ -679,7 +695,7 @@ func newOAuthBackend(definedBackends Backends, parent hcl.Body) (hcl.Body, error
 		return nil, err
 	}
 
-	return newBackend(definedBackends, &config.OAuth2{Remain: hclbody.New(&hcl.BodyContent{
+	return newBackend(definedBackends, &config.OAuth2ReqAuth{Remain: hclbody.New(&hcl.BodyContent{
 		Blocks: []*hcl.Block{
 			{Type: backend, Body: oauthBackend},
 		},
diff --git a/config/definitions.go b/config/definitions.go
index fbbcc32f..1d1924ab 100644
--- a/config/definitions.go
+++ b/config/definitions.go
@@ -6,4 +6,5 @@ type Definitions struct {
 	JWT               []*JWT               `hcl:"jwt,block"`
 	JWTSigningProfile []*JWTSigningProfile `hcl:"jwt_signing_profile,block"`
 	SAML              []*SAML              `hcl:"saml,block"`
+	OAuth2AC          []*OAuth2AC          `hcl:"beta_oauth2,block"`
 }
diff --git a/config/oauth2.go b/config/oauth2.go
index 1eaa1f36..bc257fe4 100644
--- a/config/oauth2.go
+++ b/config/oauth2.go
@@ -2,13 +2,19 @@ package config
 
 import (
 	"github.com/hashicorp/hcl/v2"
-	"github.com/hashicorp/hcl/v2/gohcl"
 )
 
-var (
-	_ BackendReference = &OAuth2{}
-	_ Inline           = &OAuth2{}
-)
+// OAuth2 defines the <OAuth2> interface.
+type OAuth2 interface {
+	BackendReference
+	Inline
+	GetClientID() string
+	GetClientSecret() string
+	GetGrantType() string
+	GetScope() *string
+	GetTokenEndpoint() string
+	GetTokenEndpointAuthMethod() *string
+}
 
 var OAuthBlockSchema = &hcl.BodySchema{
 	Blocks: []hcl.BlockHeaderSchema{
@@ -17,47 +23,3 @@ var OAuthBlockSchema = &hcl.BodySchema{
 		},
 	},
 }
-
-// OAuth2 represents the <OAuth2> object.
-type OAuth2 struct {
-	BackendName string   `hcl:"backend,optional"`
-	GrantType   string   `hcl:"grant_type"`
-	Remain      hcl.Body `hcl:",remain"`
-	Retries     *uint8   `hcl:"retries,optional"`
-}
-
-// HCLBody implements the <Inline> interface.
-func (oa OAuth2) HCLBody() hcl.Body {
-	return oa.Remain
-}
-
-// Reference implements the <Inline> interface.
-func (oa OAuth2) Reference() string {
-	return oa.BackendName
-}
-
-// Schema implements the <Inline> interface.
-func (oa OAuth2) Schema(inline bool) *hcl.BodySchema {
-	if !inline {
-		schema, _ := gohcl.ImpliedBodySchema(oa)
-		return schema
-	}
-
-	type Inline struct {
-		Backend                 *Backend `hcl:"backend,block"`
-		ClientID                string   `hcl:"client_id"`
-		ClientSecret            string   `hcl:"client_secret"`
-		Scope                   *string  `hcl:"scope,optional"`
-		TokenEndpoint           string   `hcl:"token_endpoint,optional"`
-		TokenEndpointAuthMethod *string  `hcl:"token_endpoint_auth_method,optional"`
-	}
-
-	schema, _ := gohcl.ImpliedBodySchema(&Inline{})
-
-	// A backend reference is defined, backend block is not allowed.
-	if oa.BackendName != "" {
-		schema.Blocks = nil
-	}
-
-	return newBackendSchema(schema, oa.HCLBody())
-}
diff --git a/config/oauth2ra.go b/config/oauth2ra.go
new file mode 100644
index 00000000..a609f6c6
--- /dev/null
+++ b/config/oauth2ra.go
@@ -0,0 +1,73 @@
+package config
+
+import (
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/gohcl"
+)
+
+var _ OAuth2 = &OAuth2ReqAuth{}
+
+// OAuth2ReqAuth represents the <OAuth2ReqAuth> object.
+type OAuth2ReqAuth struct {
+	BackendName             string   `hcl:"backend,optional"`
+	ClientID                string   `hcl:"client_id"`
+	ClientSecret            string   `hcl:"client_secret"`
+	GrantType               string   `hcl:"grant_type"`
+	Remain                  hcl.Body `hcl:",remain"`
+	Retries                 *uint8   `hcl:"retries,optional"`
+	Scope                   *string  `hcl:"scope,optional"`
+	TokenEndpoint           string   `hcl:"token_endpoint,optional"`
+	TokenEndpointAuthMethod *string  `hcl:"token_endpoint_auth_method,optional"`
+}
+
+func (oa OAuth2ReqAuth) HCLBody() hcl.Body {
+	return oa.Remain
+}
+
+func (oa OAuth2ReqAuth) Reference() string {
+	return oa.BackendName
+}
+
+func (oa OAuth2ReqAuth) Schema(inline bool) *hcl.BodySchema {
+	if !inline {
+		schema, _ := gohcl.ImpliedBodySchema(oa)
+		return schema
+	}
+
+	type Inline struct {
+		Backend *Backend `hcl:"backend,block"`
+	}
+
+	schema, _ := gohcl.ImpliedBodySchema(&Inline{})
+
+	// A backend reference is defined, backend block is not allowed.
+	if oa.BackendName != "" {
+		schema.Blocks = nil
+	}
+
+	return newBackendSchema(schema, oa.HCLBody())
+}
+
+func (oa OAuth2ReqAuth) GetClientID() string {
+	return oa.ClientID
+}
+
+func (oa OAuth2ReqAuth) GetClientSecret() string {
+	return oa.ClientSecret
+}
+
+func (oa OAuth2ReqAuth) GetGrantType() string {
+	return oa.GrantType
+}
+
+func (oa OAuth2ReqAuth) GetScope() *string {
+	return oa.Scope
+}
+
+func (oa OAuth2ReqAuth) GetTokenEndpoint() string {
+	return oa.TokenEndpoint
+}
+
+func (oa OAuth2ReqAuth) GetTokenEndpointAuthMethod() *string {
+	return oa.TokenEndpointAuthMethod
+}
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 4d76051a..0c156202 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -93,7 +93,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 	evalContext := conf.Context.Value(eval.ContextType).(*eval.Context)
 	confCtx := evalContext.WithClientRequest(noopReq).WithBeresps(noopResp).HCLContext()
 
-	accessControls, acErr := configureAccessControls(conf, confCtx)
+	accessControls, acErr := configureAccessControls(conf, confCtx, log, memStore)
 	if acErr != nil {
 		return nil, acErr
 	}
@@ -333,7 +333,7 @@ func newBackend(evalCtx *hcl.EvalContext, backendCtx hcl.Body, log *logrus.Entry
 	}
 
 	if blocks := oauthContent.Blocks.OfType("oauth2"); len(blocks) > 0 {
-		beConf.OAuth2 = &config.OAuth2{}
+		beConf.OAuth2 = &config.OAuth2ReqAuth{}
 
 		if diags := gohcl.DecodeBody(blocks[0].Body, evalCtx, beConf.OAuth2); diags.HasErrors() {
 			return nil, diags
@@ -355,7 +355,12 @@ func newBackend(evalCtx *hcl.EvalContext, backendCtx hcl.Body, log *logrus.Entry
 			beConf.OAuth2.Retries = &one
 		}
 
-		return transport.NewOAuth2(beConf.OAuth2, memStore, authBackend, backend)
+		oauth2, err := transport.NewOAuth2(beConf.OAuth2, authBackend)
+		if err != nil {
+			return nil, err
+		}
+
+		return transport.NewOAuth2ReqAuth(beConf.OAuth2, memStore, oauth2, backend)
 	}
 
 	return backend, nil
@@ -397,7 +402,7 @@ func whichCORS(parent *config.Server, this interface{}) *config.CORS {
 	return corsData
 }
 
-func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext) (ACDefinitions, error) {
+func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log *logrus.Entry, memStore *cache.MemoryStore) (ACDefinitions, error) {
 	accessControls := make(ACDefinitions)
 
 	if conf.Definitions != nil {
@@ -457,6 +462,27 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext) (ACD
 				return nil, err
 			}
 		}
+
+		for _, oauth2Conf := range conf.Definitions.OAuth2AC {
+			authBackend, authErr := newBackend(confCtx, oauth2Conf.Backend, log, conf.Settings.NoProxyFromEnv, memStore)
+			if authErr != nil {
+				return nil, fmt.Errorf("loading oauth2 definition failed: %s", authErr)
+			}
+
+			oauth2, err := transport.NewOAuth2(oauth2Conf, authBackend)
+			if err != nil {
+				return nil, fmt.Errorf("loading oauth2 definition failed: %s", err)
+			}
+
+			oa, err := ac.NewOAuth2Callback(oauth2Conf, oauth2)
+			if err != nil {
+				return nil, fmt.Errorf("loading oauth2 definition failed: %s", err)
+			}
+
+			if err = accessControls.Add(oauth2Conf.Name, oa, oauth2Conf.ErrorHandler); err != nil {
+				return nil, err
+			}
+		}
 	}
 
 	return accessControls, nil
diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md
index b5aaab54..7be3749a 100644
--- a/docs/REFERENCE.md
+++ b/docs/REFERENCE.md
@@ -14,11 +14,14 @@
       - [Duration](#duration)
     - [OpenAPI Block](#openapi-block)
     - [CORS Block](#cors-block)
-    - [OAuth2 Block](#oauth2-block)
+    - [OAuth2 CC Block](#oauth2-cc-block)
     - [Definitions Block](#definitions-block)
     - [Basic Auth Block](#basic-auth-block)
     - [JWT Block](#jwt-block)
     - [JWT Signing Profile Block](#jwt-signing-profile-block)
+    - [OAuth2 AC Block](#oauth2-ac-block)
+      - [PKCE Block](#pkce-block)
+      - [CSRF Block](#csrf-block)
     - [SAML Block](#saml-block)
     - [Settings Block](#settings-block)
   - [Access Control](#access-control)
@@ -132,12 +135,12 @@ The [Proxy Block](#proxy-block) creates and executes a proxy request to a backen
 
 |Context|Label|Nested block(s)|
 | :-----------| :-----------| :-----------|
-|[Endpoint Block](#endpoint-block)|&#9888; A [Proxy Block](#proxy-block) or [Request Block](#request-block) w/o a label has an implicit label `"default"`. Only **one** [Proxy Block](#proxy-block) or [Request Block](#request-block) w/ label `"default"` per [Endpoint Block](#endpoint-block) is allowed.|[Backend Block](#backend-block) (&#9888; required, if no [Backend Block](#backend-block) reference is defined or no `url` attribute is set.)|-|
+|[Endpoint Block](#endpoint-block)|&#9888; A [Proxy Block](#proxy-block) or [Request Block](#request-block) w/o a label has an implicit label `"default"`. Only **one** [Proxy Block](#proxy-block) or [Request Block](#request-block) w/ label `"default"` per [Endpoint Block](#endpoint-block) is allowed.|[Backend Block](#backend-block) (&#9888; required, if no [Backend Block](#backend-block) reference is defined or no `url` attribute is set.)|
 
 | Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
 | :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
 | `backend` |string|-|[Backend Block](#backend-block) reference, defined in [Definitions Block](#definitions-block)|&#9888; required, if no [Backend Block](#backend-block) or `url` attribute is defined.|`backend = "foo"`|
-| `url` |string|-|If defined, the host part of the URL must be the same as the `origin` attribute of the [Backend Block](#backend-block) (if defined).|-|-|-|
+| `url` |string|-|If defined, the host part of the URL must be the same as the `origin` attribute of the [Backend Block](#backend-block) (if defined).|-|-|
 |[Modifier](#modifier)|-|-|-|-|-|
 
 ### Request Block
@@ -153,13 +156,13 @@ The [Request Block](#request-block) creates and executes a request to a backend
 | Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
 | :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
 | `backend` |string|-|`backend` block reference, defined in [Definitions Block](#definitions-block)|&#9888; required, if no [Backend Block](#backend-block) is defined.|`backend = "foo"`|
-| `url` |string|-|-|If defined, the host part of the URL must be the same as the `origin` attribute of the used [Backend Block](#backend-block) or [Backend Block Reference](#backend-block) (if defined).|-|-|
+| `url` |string|-|-|If defined, the host part of the URL must be the same as the `origin` attribute of the used [Backend Block](#backend-block) or [Backend Block Reference](#backend-block) (if defined).|-|
 |`body`|string|-|-| Creates implicit default `Content-Type: text/plain` header field.|-|
 |`json_body`|null, bool, number, string, map, list|-|-|Creates implicit default `Content-Type: text/plain` header field.|-|
 | `form_body` |map|-|-|Creates implicit default `Content-Type: application/x-www-form-urlencoded` header field.|-|
 |`method`    |string|`GET`|-|-|-|
 |`headers`  |-|-|-|Same as `set_request_headers` in [Request Header](#request-header).|-|
-|`query_params`|-|-|-|Same as `set_query_params` in [Query Parameter](#query-parameter).|-|                                                                                                                                                         - |
+|`query_params`|-|-|-|Same as `set_query_params` in [Query Parameter](#query-parameter).|-|
 
 ### Response Block
 
@@ -171,9 +174,9 @@ The [Response Block](#response-block) creates and sends a client response.
 
 | Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
 | :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
-| `body`         | string|-|-|Creates implicit default `Content-Type: text/plain` header field.|-|                                              |
-| `json_body`    | null, bool, number, string, map, list|-|-|Creates implicit default `Content-Type: application/json` header field.|-|-|
-| `status`       | integer|`200`|HTTP status code.|-|-|                                                                                 |
+| `body`         | string|-|-|Creates implicit default `Content-Type: text/plain` header field.|-|
+| `json_body`    | null, bool, number, string, map, list|-|-|Creates implicit default `Content-Type: application/json` header field.|-|
+| `status`       | integer|`200`|HTTP status code.|-|-|
 | `headers`      |string|-|Same as `set_response_headers` in [Request Header](#response-header).                  |-|-|
 
 ### Backend Block
@@ -184,15 +187,15 @@ A [Backend Block](#backend-block) defines the connection to a local/remote backe
 
 |Context|Label|Nested block(s)|
 | :-----------| :-----------| :-----------|
-| [Definitions Block](#definitions-block), [Proxy Block](#proxy-block), [Request Block](#request-block)| &#9888; required, when defined in [Definitions Block](#definitions-block)| [OpenAPI Block](#openapi-block), [OAuth2 Block](#oauth2-block)|
+| [Definitions Block](#definitions-block), [Proxy Block](#proxy-block), [Request Block](#request-block)| &#9888; required, when defined in [Definitions Block](#definitions-block)| [OpenAPI Block](#openapi-block), [OAuth2 CC Block](#oauth2-cc-block)|
 
 | Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
 | :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
 | `basic_auth`                    | string|-|Basic auth for the upstream request. | format is `username:password`|-|
 | `hostname`                      | string |-|Value of the HTTP host header field for the origin request. |Since `hostname` replaces the request host the value will also be used for a server identity check during a TLS handshake with the origin.|-|
-| `origin`                        |string|-|URL to connect to for backend requests.|&#9888; required.  &#9888; Must start with the scheme `http://...`.|-|                                                                                          |
-| `path`                          | string|-|Changeable part of upstream URL.|-|-|                                                                                                                                                                       |
-| `path_prefix`  | string|-|Prefixes all backend request paths with the given prefix|&#9888; Must start with the scheme `http://...`. |-|-|                                                                                    |
+| `origin`                        |string|-|URL to connect to for backend requests.|&#9888; required.  &#9888; Must start with the scheme `http://...`.|-|
+| `path`                          | string|-|Changeable part of upstream URL.|-|-|
+| `path_prefix`  | string|-|Prefixes all backend request paths with the given prefix|&#9888; Must start with the scheme `http://...`. |-|
 | `connect_timeout`                | [duration](#duration) | `10s`      | The total timeout for dialing and connect to the origin.   |-                                   |-|
 | `disable_certificate_validation` | bool               | `false`       | Disables the peer certificate validation.                                              |      - |-|
 | `disable_connection_reuse`       | bool               | `false`        | Disables reusage of connections to the origin.                                          |    -  |-|
@@ -201,7 +204,7 @@ A [Backend Block](#backend-block) defines the connection to a local/remote backe
 | `proxy`                          | string             | -| A proxy URL for the related origin request.      |-   | `http://SERVER-IP_OR_NAME:PORT`|
 | `timeout`                        | [duration](#duration) | `300s`          | The total deadline duration a backend request has for write and read/pipe.               |-     |-|
 | `ttfb_timeout`                   | [duration](#duration) | `60s`           | The duration from writing the full request to the origin and receiving the answer.        |-    |-|
-| [Modifier](#modifier)           |- |-|All [Modifier](#modifier)|-|-|                                                                                                       |
+| [Modifier](#modifier)           |- |-|All [Modifier](#modifier)|-|-|
 
 #### Duration
 
@@ -233,7 +236,7 @@ In this case the response validation will fail if not ignored too.
 
 | Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
 | :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
-| `file`                       |string|-|OpenAPI yaml definition file.|&#9888; required|-|                                     |
+| `file`                       |string|-|OpenAPI yaml definition file.|&#9888; required|-|
 | `ignore_request_violations`  |bool|`false`|Log request validation results, skip error handling. |-|-|
 | `ignore_response_violations` |bool|`false`|Log response validation results, skip error handling.|-|-|
 
@@ -251,11 +254,13 @@ The [CORS Block](#cors-block) configures the CORS (Cross-Origin Resource Sharing
 | Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
 | :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
 | `allowed_origins`   | string|-|A list of allowed origin(s).|Can be either of: a string with a single specific origin, `"*"` (all origins are allowed) or an array of specific origins | `allowed_origins = ["https://www.example.com", "https://www.another.host.org"]`|
-| `allow_credentials` |bool|`false`| Set to `true` if the response can be shared with credentialed requests (containing `Cookie` or `Authorization` HTTP header fields).|-|-|                                                                                                                                                         |
-| `disable`           | bool|`false`|Set to `true` to disable the inheritance of CORS from the [Server Block](#server-block) in [Files Block](#files-block), [SPA Block](#spa-block) and [API Block](#api-block) contexts.|-|-|                                                                                                       |
-| `max_age`           |[duration](#duration)|-|Indicates the time the information provided by the `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` response HTTP header fields.|&#9888; Can be cached (string with time unit, e.g. `"1h"`)|-|                                                                                               |
+| `allow_credentials` |bool|`false`| Set to `true` if the response can be shared with credentialed requests (containing `Cookie` or `Authorization` HTTP header fields).|-|-|
+| `disable`           | bool|`false`|Set to `true` to disable the inheritance of CORS from the [Server Block](#server-block) in [Files Block](#files-block), [SPA Block](#spa-block) and [API Block](#api-block) contexts.|-|-|
+| `max_age`           |[duration](#duration)|-|Indicates the time the information provided by the `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` response HTTP header fields.|&#9888; Can be cached (string with time unit, e.g. `"1h"`)|-|
 
-### OAuth2 Block
+### OAuth2 CC Block
+
+The OAuth2 CC Block (`oauth2 {}`) configures the OAuth2 Client Credentials flow to request a bearer token for the backend request.
 
 |Context|Label|Nested block(s)|
 | :-----------| :-----------| :-----------|
@@ -263,14 +268,14 @@ The [CORS Block](#cors-block) configures the CORS (Cross-Origin Resource Sharing
 
 | Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
 | :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
-| `backend`                       |string|-|[Backend Block Reference](#backend-block)|-|-|ul>                                                                                                                                                                                                                                                                                 |
-| `grant_type`                    |string|-|-|&#9888; required, available value(s): `client_credentials`|-|                                                                                                                                                                                                                                                                           |
+| `backend`                       |string|-|[Backend Block Reference](#backend-block)|-|-|
+| `grant_type`                    |string|-|-|&#9888; required, available value(s): `client_credentials`|-|
 | `token_endpoint`   |string|-|URL of the token endpoint at the authorization server.|&#9888; required|-|
-| `client_id`|  string|-|The client identifier.|&#9888; required|-|                                                                                                                                ||
+| `client_id`|  string|-|The client identifier.|&#9888; required|-|
 | `client_secret` |string|-|The client password.|&#9888; required.|-|
-| `retries` |integer|`1` | The number of retries to get the token and resource, if the resource-request responds with `401 Unauthorized` HTTP status code.|-|-|                                                                                                                                                                      |
-| `token_endpoint_auth_method` |string|`client_secret_post`|Defines the method to authenticate the client at the token endpoint.|If set to , the client credentials are transported in the request body. If set to `client_secret_basic`, the client credentials are transported via Basic Authentication.|-|
-| `scope`                      |string|-|  A space separated list of requested scopes for the access token.|-|
+| `retries` |integer|`1` | The number of retries to get the token and resource, if the resource-request responds with `401 Unauthorized` HTTP status code.|-|-|
+| `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.|-|
+| `scope`                      |string|-|  A space separated list of requested scopes for the access token.|-| `scope = "read write"` |
 
 ### Definitions Block
 
@@ -279,7 +284,7 @@ Use the [Definitions Block](#definitions-block) to define configurations you wan
 
 |Context|Label|Nested block(s)|
 | :-----------| :-----------| :-----------|
-|-|no label|[Backend Block(s)](#backend-block), [Basic Auth Block(s)](#basic-auth-block), [JWT Block(s)](#jwt-block), [JWT Signing Profile Block(s)](#jwt-signing-profile-block), [SAML Block(s)](#saml-block)|
+|-|no label|[Backend Block(s)](#backend-block), [Basic Auth Block(s)](#basic-auth-block), [JWT Block(s)](#jwt-block), [JWT Signing Profile Block(s)](#jwt-signing-profile-block), [SAML Block(s)](#saml-block), [OAuth2 AC Block(s)](#oauth2-ac-block)|
 
 <!--Example???-->
 
@@ -308,7 +313,7 @@ by `htpasswd_file` otherwise.
 
 ### JWT Block
 
-The[JWT Block](#jwt-block) lets you configure JSON Web Token access control for your gateway.
+The [JWT Block](#jwt-block) lets you configure JSON Web Token access control for your gateway.
 Like all [Access Control](#access-control) types, the [JWT Block](#jwt-block) is defined in
 the [Definitions Block](#definitions-block) and can be referenced in all configuration blocks by its
 required _label_.
@@ -345,6 +350,59 @@ by its required _label_.
 |`ttl`  |string|-|The token's time-to-live (creates the `exp` claim).|-|-|
 | `claims` |string|-|Default claims for the JWT payload.|-|-|
 
+### OAuth2 AC Block
+
+The OAuth2 AC Block (`beta_oauth2 {}`) lets you configure the `beta_oauth_authorization_url()` [function](#functions) and an access
+control for an OAuth2 Authorization Code Grant Flow redirect endpoint.
+Like all [Access Control](#access-control) types, the [OAuth2 AC Block](#oauth2-ac-block) is defined in
+the [Definitions Block](#definitions-block) and can be referenced in all configuration blocks by its
+required _label_.
+
+|Context|Label|Nested block(s)|
+| :-----------| :-----------| :-----------|
+| [Definitions Block](#definitions-block)| &#9888; required | [PKCE Block](#pkce-block), [CSRF Block](#csrf-block) |
+
+| Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
+| :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
+| `backend`                       |string|-|[Backend Block Reference](#backend-block)| &#9888; Do not disable the peer certificate validation with `disable_certificate_validation = true`! |-|
+| `authorization_endpoint` | string |-| The authorization server endpoint URL used for authorization. |&#9888; required|-|
+| `token_endpoint` | string |-| The authorization server endpoint URL used for requesting the token. |&#9888; required|-|
+| `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.|-|
+| `userinfo_endpoint` | string |-| The authorization server (OIDC server) endpoint URL used for requesting information about the user. |Only used for OpenID Connect.|-|
+| `redirect_uri` | string |-| The Couper endpoint for receiving the authorization code. |&#9888; required|-|
+| `grant_type` |string|-| The grant type. |&#9888; required, available value(s): `client_credentials`|-|
+| `client_id`|  string|-|The client identifier.|&#9888; required|-|
+| `client_secret` |string|-|The client password.|&#9888; required.|-|
+| `scope` |string|-| A space separated list of requested scopes for the access token.|Use at least `openid` for OpenID Connect| `scope = "openid profile read"` |
+
+To configure protection of the OAuth2 flow against Cross-Site Request Forgery (CSRF) use either the `pkce` or the `csrf` block. If the authorization server supports PKCE, we recommend `pkce`.
+
+##### PKCE Block
+
+Use PKCE (Proof Key for Code Exchange) as defined in [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) for protection against CSRF and code injection.
+
+|Context|Label|Nested block(s)|
+| :-----------| :-----------| :-----------|
+| [OAuth2 AC Block](#oauth2-ac-block)| no label |-|
+
+| Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
+| :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
+| `code_challenge_method` | string | - | The method to calculate the PKCE code challenge. |&#9888; required, available values: `S256` or (not recommended) `plain`|-|
+| `code_verifier_value` | string or expression | - | The value of the code verifier. |&#9888; required; e.g. using cookie value created with [`beta_oauth_code_verifier()` function](#functions)|`code_verifier_value = request.cookies.code_verifier` |
+
+##### CSRF Block
+
+Use `state` or `nonce` for protection against CSRF.
+
+|Context|Label|Nested block(s)|
+| :-----------| :-----------| :-----------|
+| [OAuth2 AC Block](#oauth2-ac-block)| no label |-|
+
+| Attribute(s) | Type |Default|Description|Characteristic(s)| Example|
+| :------------------------------ | :--------------- | :--------------- | :--------------- | :--------------- | :--------------- |
+| `token_param`  | string | - | The name of the query parameter for the hashed CSRF token. |&#9888; required, available values: `state`, `nonce`.|-|
+| `token_value`  | string or expression | - | The value of the CSRF token. | &#9888; required; e.g. using cookie value created with [`beta_oauth_csrf_token()` function](#functions) |`token_value = request.cookies.csrf_token` |
+
 ### SAML Block
 
 The  [SAML Block(s)](#saml-block) lets you configure the `saml_sso_url()` [function](#functions) and an access
@@ -434,7 +492,28 @@ since these references get evaluated at start.
 | `body`                           | The request message body                                                                                                                                                                                                                                                            |
 | `form_body.<name>`               | Parameter in a `application/x-www-form-urlencoded` body                                                                                                                                                                                                                             |
 | `json_body.<name>`               | Access json decoded object properties. Media type must be `application/json` or `application/*+json`.                                                                                                                                                                               |
-| `context.<name>.<property_name>` | Request context containing claims from JWT used for [Access Control](#access-control) or information from a SAML assertion, `<name>` being the [JWT Block's](#jwt-block) or [SAML Block's](#saml-block) label and `property_name` being the claim's or assertion information's name |
+| `context.<name>.<property_name>` | Request context containing information from the [Access Control](#access-control). |
+
+The value of `context.<name>` depends on the type of block referenced by `<name>`.
+
+For a [JWT Block](#jwt-block), the variable contains claims from the JWT used for [Access Control](#access-control).
+
+For a [SAML Block](#saml-block), the variable contains
+
+ - `sub`: the `NameID` of the SAML assertion
+ - `exp`: optional expiration date (value of `SessionNotOnOrAfter` of the SAML assertion)
+ - `attributes`: a map of attributes from the SAML assertion
+
+For an [OAuth2 AC Block](#oauth2-ac-block), the variable contains the response from the token endpoint, e.g.
+ - `access_token`: the access token retrieved from the token endpoint
+ - `token_type`: the token type
+ - `expires_in`: the token lifetime
+ - `scope`: the granted scope (if different from the requested scope)
+
+and for OIDC additionally:
+ - `id_token`: the ID token
+ - `id_token_claims`: a map of claims from the ID token
+ - `userinfo`: a map of claims retrieved from the userinfo endpoint
 
 ### `backend_requests`
 
@@ -481,6 +560,9 @@ To access the HTTP status code of the `default` response use `backend_responses.
 | `json_encode`   | Returns a JSON serialization of the given value.                                                                                                                                                                                                                                                     |
 | `jwt_sign`      | jwt_sign creates and signs a JSON Web Token (JWT) from information from a referenced [JWT Signing Profile Block](#jwt-signing-profile-block) and additional claims provided as a function parameter.                                                                                                 |
 | `merge`         | Deep-merges two or more of either objects or tuples. `null` arguments are ignored. A `null` attribute value in an object removes the previous attribute value. An attribute value with a different type than the current value is set as the new value. `merge()` with no parameters returns `null`. |
+| `beta_oauth_authorization_url` | Creates an OAuth2 authorization URL from a referenced [OAuth2 AC Block](#oauth2-ac-block).                                                                                                                                                                                            |
+| `beta_oauth_code_verifier`  | Creates an OAuth2 PKCE code verifier, as specified in RFC 7636, e.g. to be used in a cookie, when using the PKCE for CSRF protection. Multiple calls of this function in the same client request context return the same value.                                                          |
+| `beta_oauth_csrf_token`     | Alias for `beta_oauth_code_verifier()` creating a CSRF token, e.g. to be used in a cookie, when using the `state` parameter for CSRF protection.                                                                                                                                         |
 | `saml_sso_url`  | Creates a SAML SingleSignOn URL (including the `SAMLRequest` parameter) from a referenced [SAML Block](#saml-block).                                                                                                                                                                                 |
 | `to_lower`      | Converts a given string to lowercase.                                                                                                                                                                                                                                                                |
 | `to_upper`      | Converts a given string to uppercase.                                                                                                                                                                                                                                                                |
diff --git a/errors/type_defintions.go b/errors/type_defintions.go
index 659e5df2..5b4cbf28 100644
--- a/errors/type_defintions.go
+++ b/errors/type_defintions.go
@@ -14,4 +14,6 @@ var Definitions = []*Error{
 	AccessControl.Kind("jwt").Kind("jwt_token_invalid"),
 
 	AccessControl.Kind("saml2"),
+
+	AccessControl.Kind("oauth2"),
 }
diff --git a/errors/types_generated.go b/errors/types_generated.go
index 2843381f..6c01108a 100644
--- a/errors/types_generated.go
+++ b/errors/types_generated.go
@@ -10,6 +10,7 @@ var (
 	JwtTokenMissing             = Definitions[4]
 	JwtTokenInvalid             = Definitions[5]
 	Saml2                       = Definitions[6]
+	Oauth2                      = Definitions[7]
 )
 
 // typeDefinitions holds all related error definitions which are
@@ -26,6 +27,7 @@ var types = typeDefinitions{
 	"jwt_token_missing":              JwtTokenMissing,
 	"jwt_token_invalid":              JwtTokenInvalid,
 	"saml2":                          Saml2,
+	"oauth2":                         Oauth2,
 }
 
 // IsKnown tells the configuration callee if Couper
diff --git a/eval/context.go b/eval/context.go
index 68cfaf67..05ec5a65 100644
--- a/eval/context.go
+++ b/eval/context.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
+	pkce "github.com/jimlambrt/go-oauth-pkce-code-verifier"
 	"github.com/zclconf/go-cty/cty"
 	"github.com/zclconf/go-cty/cty/function"
 	"github.com/zclconf/go-cty/cty/function/stdlib"
@@ -45,6 +46,8 @@ type Context struct {
 	bufferOption BufferOption
 	eval         *hcl.EvalContext
 	inner        context.Context
+	memorize     map[string]interface{}
+	oauth2       []*config.OAuth2AC
 	profiles     []*config.JWTSigningProfile
 	saml         []*config.SAML
 }
@@ -88,10 +91,14 @@ func (c *Context) WithClientRequest(req *http.Request) *Context {
 		bufferOption: c.bufferOption,
 		eval:         cloneContext(c.eval),
 		inner:        c.inner,
+		memorize:     make(map[string]interface{}),
+		oauth2:       c.oauth2[:],
 		profiles:     c.profiles[:],
 		saml:         c.saml[:],
 	}
 
+	ctx.createOAuth2Functions()
+
 	if rc := req.Context(); rc != nil {
 		ctx.inner = context.WithValue(rc, ContextType, ctx)
 	}
@@ -134,6 +141,8 @@ func (c *Context) WithBeresps(beresps ...*http.Response) *Context {
 		bufferOption: c.bufferOption,
 		eval:         cloneContext(c.eval),
 		inner:        c.inner,
+		memorize:     c.memorize,
+		oauth2:       c.oauth2[:],
 		profiles:     c.profiles[:],
 		saml:         c.saml[:],
 	}
@@ -188,13 +197,23 @@ func (c *Context) WithJWTProfiles(profiles []*config.JWTSigningProfile) *Context
 	return c
 }
 
+// WithOAuth2 initially setup the lib.FnOAuthAuthorizationUrl function.
+func (c *Context) WithOAuth2(o []*config.OAuth2AC) *Context {
+	c.oauth2 = o
+	if c.oauth2 == nil {
+		c.oauth2 = make([]*config.OAuth2AC, 0)
+	}
+	return c
+}
+
 // WithSAML initially setup the lib.FnSamlSsoUrl function.
 func (c *Context) WithSAML(s []*config.SAML) *Context {
 	c.saml = s
 	if c.saml == nil {
 		c.saml = make([]*config.SAML, 0)
 	}
-	updateFunctions(c)
+	samlfn := lib.NewSamlSsoUrlFunction(c.saml)
+	c.eval.Functions[lib.FnSamlSsoUrl] = samlfn
 	return c
 }
 
@@ -202,13 +221,39 @@ func (c *Context) HCLContext() *hcl.EvalContext {
 	return c.eval
 }
 
+// createOAuth2Functions creates the listed OAuth2 functions for the client request context.
+func (c *Context) createOAuth2Functions() {
+	if c.oauth2 != nil {
+		oauth2fn := lib.NewOAuthAuthorizationUrlFunction(c.oauth2, c.getCodeVerifier)
+		c.eval.Functions[lib.FnOAuthAuthorizationUrl] = oauth2fn
+	}
+	c.eval.Functions[lib.FnOAuthCodeVerifier] = lib.NewOAuthCodeVerifierFunction(c.getCodeVerifier)
+	c.eval.Functions[lib.FnOAuthCsrfToken] = c.eval.Functions[lib.FnOAuthCodeVerifier]
+	c.eval.Functions[lib.FnOAuthCodeChallenge] = lib.NewOAuthCodeChallengeFunction(c.getCodeVerifier)
+	c.eval.Functions[lib.FnOAuthHashedCsrfToken] = lib.NewOAuthHashedCsrfTokenFunction(c.getCodeVerifier)
+}
+
+func (c *Context) getCodeVerifier() (*pkce.CodeVerifier, error) {
+	cv, ok := c.memorize[lib.CodeVerifier]
+	var err error
+	if !ok {
+		cv, err = pkce.CreateCodeVerifier()
+		if err != nil {
+			return nil, err
+		}
+
+		c.memorize[lib.CodeVerifier] = cv
+	}
+
+	codeVerifier, _ := cv.(*pkce.CodeVerifier)
+
+	return codeVerifier, nil
+}
+
 // updateFunctions recreates the listed functions with latest evaluation context.
 func updateFunctions(ctx *Context) {
 	jwtfn := lib.NewJwtSignFunction(ctx.profiles, ctx.eval)
 	ctx.eval.Functions[lib.FnJWTSign] = jwtfn
-
-	samlfn := lib.NewSamlSsoUrlFunction(ctx.saml)
-	ctx.eval.Functions[lib.FnSamlSsoUrl] = samlfn
 }
 
 const defaultMaxMemory = 32 << 20 // 32 MB
diff --git a/eval/lib/jwt.go b/eval/lib/jwt.go
index 4d3b67ab..feb58b48 100644
--- a/eval/lib/jwt.go
+++ b/eval/lib/jwt.go
@@ -98,14 +98,6 @@ func NewJwtSignFunction(jwtSigningProfiles []*config.JWTSigningProfile, confCtx
 				mapClaims[k] = v
 			}
 
-			// create token
-			signingMethod := jwt.GetSigningMethod(signingProfile.SignatureAlgorithm)
-			if signingMethod == nil {
-				return cty.StringVal(""), fmt.Errorf("no signing method for given algorithm: %s", signingProfile.SignatureAlgorithm)
-			}
-
-			token := jwt.NewWithClaims(signingMethod, mapClaims)
-
 			var key interface{}
 			if rsaKey, exist := rsaKeys[signingProfile.Name]; exist {
 				if rsaKey == rsaParseError {
@@ -116,8 +108,7 @@ func NewJwtSignFunction(jwtSigningProfiles []*config.JWTSigningProfile, confCtx
 				key = signingProfile.KeyBytes
 			}
 
-			// sign token
-			tokenString, err := token.SignedString(key)
+			tokenString, err := CreateJWT(signingProfile.SignatureAlgorithm, key, mapClaims)
 			if err != nil {
 				return cty.StringVal(""), err
 			}
@@ -126,3 +117,16 @@ func NewJwtSignFunction(jwtSigningProfiles []*config.JWTSigningProfile, confCtx
 		},
 	})
 }
+
+func CreateJWT(signatureAlgorithm string, key interface{}, mapClaims jwt.MapClaims) (string, error) {
+	signingMethod := jwt.GetSigningMethod(signatureAlgorithm)
+	if signingMethod == nil {
+		return "", fmt.Errorf("no signing method for given algorithm: %s", signatureAlgorithm)
+	}
+
+	// create token
+	token := jwt.NewWithClaims(signingMethod, mapClaims)
+
+	// sign token
+	return token.SignedString(key)
+}
diff --git a/eval/lib/oauth2.go b/eval/lib/oauth2.go
new file mode 100644
index 00000000..1d95fda5
--- /dev/null
+++ b/eval/lib/oauth2.go
@@ -0,0 +1,143 @@
+package lib
+
+import (
+	"fmt"
+	"net/url"
+
+	pkce "github.com/jimlambrt/go-oauth-pkce-code-verifier"
+	"github.com/zclconf/go-cty/cty"
+	"github.com/zclconf/go-cty/cty/function"
+
+	"github.com/avenga/couper/config"
+)
+
+const (
+	FnOAuthAuthorizationUrl = "beta_oauth_authorization_url"
+	FnOAuthCodeVerifier     = "beta_oauth_code_verifier"
+	FnOAuthCodeChallenge    = "beta_oauth_code_challenge"
+	FnOAuthCsrfToken        = "beta_oauth_csrf_token"
+	FnOAuthHashedCsrfToken  = "beta_oauth_hashed_csrf_token"
+	CodeVerifier            = "code_verifier"
+	CcmPlain                = "plain"
+	CcmS256                 = "S256"
+)
+
+func NewOAuthAuthorizationUrlFunction(oauth2Configs []*config.OAuth2AC, verifier func() (*pkce.CodeVerifier, error)) function.Function {
+	oauth2s := make(map[string]*config.OAuth2AC)
+	for _, o := range oauth2Configs {
+		oauth2s[o.Name] = o
+	}
+	return function.New(&function.Spec{
+		Params: []function.Parameter{
+			{
+				Name: "oauth2_label",
+				Type: cty.String,
+			},
+		},
+		Type: function.StaticReturnType(cty.String),
+		Impl: func(args []cty.Value, _ cty.Type) (ret cty.Value, err error) {
+			label := args[0].AsString()
+			oauth2 := oauth2s[label]
+
+			oauthAuthorizationUrl, err := url.Parse(oauth2.AuthorizationEndpoint)
+			if err != nil {
+				return cty.StringVal(""), err
+			}
+
+			query := oauthAuthorizationUrl.Query()
+			query.Set("response_type", "code")
+			query.Set("client_id", oauth2.ClientID)
+			query.Set("redirect_uri", *oauth2.RedirectURI)
+			if oauth2.Scope != nil {
+				query.Set("scope", *oauth2.Scope)
+			}
+
+			if oauth2.Pkce != nil && oauth2.Pkce.CodeChallengeMethod != "" {
+				query.Set("code_challenge_method", oauth2.Pkce.CodeChallengeMethod)
+				codeChallenge, err := createCodeChallenge(verifier, oauth2.Pkce.CodeChallengeMethod)
+				if err != nil {
+					return cty.StringVal(""), err
+				}
+
+				query.Set("code_challenge", codeChallenge)
+			} else if oauth2.Csrf != nil && (oauth2.Csrf.TokenParam == "state" || oauth2.Csrf.TokenParam == "nonce") {
+				hashedCsrfToken, err := createCodeChallenge(verifier, CcmS256)
+				if err != nil {
+					return cty.StringVal(""), err
+				}
+
+				query.Set(oauth2.Csrf.TokenParam, hashedCsrfToken)
+			}
+			oauthAuthorizationUrl.RawQuery = query.Encode()
+
+			return cty.StringVal(oauthAuthorizationUrl.String()), nil
+		},
+	})
+}
+
+func NewOAuthCodeVerifierFunction(verifier func() (*pkce.CodeVerifier, error)) function.Function {
+	return function.New(&function.Spec{
+		Params: []function.Parameter{},
+		Type:   function.StaticReturnType(cty.String),
+		Impl: func(args []cty.Value, _ cty.Type) (ret cty.Value, err error) {
+			codeVerifier, err := verifier()
+			if err != nil {
+				return cty.StringVal(""), err
+			}
+
+			return cty.StringVal(codeVerifier.String()), nil
+		},
+	})
+}
+
+func NewOAuthCodeChallengeFunction(verifier func() (*pkce.CodeVerifier, error)) function.Function {
+	return function.New(&function.Spec{
+		Params: []function.Parameter{
+			{
+				Name: "code_challenge_method",
+				Type: cty.String,
+			},
+		},
+		Type: function.StaticReturnType(cty.String),
+		Impl: func(args []cty.Value, _ cty.Type) (ret cty.Value, err error) {
+			method := args[0].AsString()
+			codeChallenge, err := createCodeChallenge(verifier, method)
+			if err != nil {
+				return cty.StringVal(""), err
+			}
+
+			return cty.StringVal(codeChallenge), nil
+		},
+	})
+}
+
+func NewOAuthHashedCsrfTokenFunction(verifier func() (*pkce.CodeVerifier, error)) function.Function {
+	return function.New(&function.Spec{
+		Params: []function.Parameter{},
+		Type:   function.StaticReturnType(cty.String),
+		Impl: func(args []cty.Value, _ cty.Type) (ret cty.Value, err error) {
+			hashedCsrfToken, err := createCodeChallenge(verifier, CcmS256)
+			if err != nil {
+				return cty.StringVal(""), err
+			}
+
+			return cty.StringVal(hashedCsrfToken), nil
+		},
+	})
+}
+
+func createCodeChallenge(verifier func() (*pkce.CodeVerifier, error), method string) (string, error) {
+	codeVerifier, err := verifier()
+	if err != nil {
+		return "", err
+	}
+
+	switch method {
+	case CcmS256:
+		return codeVerifier.CodeChallengeS256(), nil
+	case CcmPlain:
+		return codeVerifier.CodeChallengePlain(), nil
+	default:
+		return "", fmt.Errorf("unsupported code challenge method: %s", method)
+	}
+}
diff --git a/go.mod b/go.mod
index 143e395e..6891de98 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
 	github.com/fatih/color v1.10.0
 	github.com/getkin/kin-openapi v0.49.0
 	github.com/hashicorp/hcl/v2 v2.10.0
+	github.com/jimlambrt/go-oauth-pkce-code-verifier v0.0.0-20201220003123-6363600dffda
 	github.com/rs/xid v1.2.1
 	github.com/russellhaering/gosaml2 v0.6.0
 	github.com/russellhaering/goxmldsig v1.1.0
diff --git a/go.sum b/go.sum
index 51fb36d4..e9ff4713 100644
--- a/go.sum
+++ b/go.sum
@@ -33,6 +33,8 @@ github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/hashicorp/hcl/v2 v2.10.0 h1:1S1UnuhDGlv3gRFV4+0EdwB+znNP5HmcGbIqwnSCByg=
 github.com/hashicorp/hcl/v2 v2.10.0/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg=
+github.com/jimlambrt/go-oauth-pkce-code-verifier v0.0.0-20201220003123-6363600dffda h1:bQONHNUnOORYC/LwlHqYTRdK40usp79dLXFffPKxLz0=
+github.com/jimlambrt/go-oauth-pkce-code-verifier v0.0.0-20201220003123-6363600dffda/go.mod h1:dRScMNYlkRbOXpUiqNHR6mSBOzXgepLOFIX5M4RFgbk=
 github.com/jonboulle/clockwork v0.2.0 h1:J2SLSdy7HgElq8ekSl2Mxh6vrRNFxqbXGenYH2I02Vs=
 github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
diff --git a/handler/transport/oauth2.go b/handler/transport/oauth2.go
index 9b799b44..d30bfea9 100644
--- a/handler/transport/oauth2.go
+++ b/handler/transport/oauth2.go
@@ -8,174 +8,91 @@ import (
 	"io/ioutil"
 	"net/http"
 	"net/url"
-	"strings"
 
-	"github.com/avenga/couper/cache"
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/request"
 	"github.com/avenga/couper/errors"
 	"github.com/avenga/couper/eval"
-	"github.com/avenga/couper/internal/seetie"
 )
 
-var _ http.RoundTripper = &OAuth2{}
-
 // OAuth2 represents the transport <OAuth2> object.
 type OAuth2 struct {
-	backend  http.RoundTripper
-	config   *config.OAuth2
-	memStore *cache.MemoryStore
-	next     http.RoundTripper
+	Backend http.RoundTripper
+	config  config.OAuth2
 }
 
-type OAuth2Credentials struct {
-	ClientID                string
-	ClientSecret            string
-	Scope                   *string
-	StorageKey              string
-	TokenEndpointAuthMethod *string
+type OAuth2RequestConfig struct {
+	Code         *string
+	CodeVerifier *string
+	RedirectURI  *string
+	StorageKey   string
 }
 
-// NewOAuth2 creates a new <http.RoundTripper> object.
-func NewOAuth2(conf *config.OAuth2, memStore *cache.MemoryStore,
-	backend, next http.RoundTripper) (http.RoundTripper, error) {
-	const grantType = "client_credentials"
-	if conf.GrantType != grantType {
-		return nil, errors.Backend.Label(conf.BackendName).Message("grant_type not supported: " + conf.GrantType)
+// NewOAuth2 creates a new <OAuth2> object.
+func NewOAuth2(conf config.OAuth2, backend http.RoundTripper) (*OAuth2, error) {
+	if teAuthMethod := conf.GetTokenEndpointAuthMethod(); teAuthMethod != nil {
+		if *teAuthMethod != "client_secret_basic" && *teAuthMethod != "client_secret_post" {
+			return nil, fmt.Errorf("unsupported 'token_endpoint_auth_method': %s", *teAuthMethod)
+		}
 	}
-
 	return &OAuth2{
-		backend:  backend,
-		config:   conf,
-		memStore: memStore,
-		next:     next,
+		Backend: backend,
+		config:  conf,
 	}, nil
 }
 
-// RoundTrip implements the <http.RoundTripper> interface.
-func (oa *OAuth2) RoundTrip(req *http.Request) (*http.Response, error) {
-	credentials, err := oa.getCredentials(req)
-	if err != nil {
-		return nil, errors.Backend.Label(oa.config.BackendName).With(err)
-	}
-
-	if data := oa.memStore.Get(credentials.StorageKey); data != "" {
-		token, terr := oa.readAccessToken(data)
-		if terr != nil {
-			return nil, errors.Backend.Label(oa.config.BackendName).Message("token read error").With(terr)
-		}
-
-		req.Header.Set("Authorization", "Bearer "+token)
-
-		return oa.next.RoundTrip(req)
-	}
+func (oa *OAuth2) GetRequestConfig(req *http.Request) (*OAuth2RequestConfig, error) {
+	return &OAuth2RequestConfig{
+		// Backend is build up via config and token_endpoint will configure the backend,
+		// use the backend memory location here.
+		StorageKey: fmt.Sprintf("%p|%s|%s", &oa.Backend, oa.config.GetClientID(), oa.config.GetClientSecret()),
+	}, nil
+}
 
-	tokenReq, err := oa.newTokenRequest(req.Context(), credentials)
+func (oa *OAuth2) RequestToken(ctx context.Context, requestConfig *OAuth2RequestConfig) ([]byte, error) {
+	tokenReq, err := oa.newTokenRequest(ctx, requestConfig)
 	if err != nil {
 		return nil, err
 	}
 
-	tokenRes, err := oa.backend.RoundTrip(tokenReq)
+	tokenRes, err := oa.Backend.RoundTrip(tokenReq)
 	if err != nil {
 		return nil, err
 	}
 
-	if tokenRes.StatusCode != http.StatusOK {
-		return nil, errors.Backend.Label(oa.config.BackendName).Message("token request failed")
-	}
-
 	tokenResBytes, err := ioutil.ReadAll(tokenRes.Body)
 	if err != nil {
-		return nil, errors.Backend.Label(oa.config.BackendName).Message("token request read error").With(err)
+		return nil, errors.Backend.Label(oa.config.Reference()).Message("token request read error").With(err)
 	}
 
-	token, err := oa.updateAccessToken(string(tokenResBytes), credentials.StorageKey)
-	if err != nil {
-		return nil, errors.Backend.Label(oa.config.BackendName).Message("token update error").With(err)
-	}
-
-	req.Header.Set("Authorization", "Bearer "+token)
-
-	res, err := oa.next.RoundTrip(req)
-
-	if res != nil && res.StatusCode == http.StatusUnauthorized {
-		oa.memStore.Del(credentials.StorageKey)
-
-		ctx := req.Context()
-		if retries, ok := ctx.Value(request.TokenRequestRetries).(uint8); !ok || retries < *oa.config.Retries {
-			ctx = context.WithValue(ctx, request.TokenRequestRetries, retries+1)
-
-			req.Header.Del("Authorization")
-			*req = *req.WithContext(ctx)
-
-			return oa.RoundTrip(req)
-		}
+	if tokenRes.StatusCode != http.StatusOK {
+		return nil, errors.Backend.Label(oa.config.Reference()).Messagef("token request failed, response=%q", string(tokenResBytes))
 	}
 
-	return res, err
+	return tokenResBytes, nil
 }
 
-func (oa *OAuth2) getCredentials(req *http.Request) (*OAuth2Credentials, error) {
-	content, _, diags := oa.config.Remain.PartialContent(oa.config.Schema(true))
-	if diags.HasErrors() {
-		return nil, diags
-	}
-
-	evalContext, _ := req.Context().Value(eval.ContextType).(*eval.Context)
-
-	id, idOK := content.Attributes["client_id"]
-	idv, _ := id.Expr.Value(evalContext.HCLContext())
-	clientID := seetie.ValueToString(idv)
-
-	secret, secretOK := content.Attributes["client_secret"]
-	secretv, _ := secret.Expr.Value(evalContext.HCLContext())
-	clientSecret := seetie.ValueToString(secretv)
-
-	if !idOK || !secretOK {
-		return nil, fmt.Errorf("missing credentials")
-	}
-
-	var scope, teAuthMethod *string
+func (oa *OAuth2) newTokenRequest(ctx context.Context, requestConfig *OAuth2RequestConfig) (*http.Request, error) {
+	post := url.Values{}
+	grantType := oa.config.GetGrantType()
+	post.Set("grant_type", grantType)
 
-	if v, ok := content.Attributes["scope"]; ok {
-		ctyVal, _ := v.Expr.Value(evalContext.HCLContext())
-		strVal := strings.TrimSpace(seetie.ValueToString(ctyVal))
-		scope = &strVal
+	if scope := oa.config.GetScope(); scope != nil && grantType != "authorization_code" {
+		post.Set("scope", *scope)
 	}
-
-	if v, ok := content.Attributes["token_endpoint_auth_method"]; ok {
-		ctyVal, _ := v.Expr.Value(evalContext.HCLContext())
-		strVal := strings.TrimSpace(seetie.ValueToString(ctyVal))
-		teAuthMethod = &strVal
+	if requestConfig.RedirectURI != nil {
+		post.Set("redirect_uri", *requestConfig.RedirectURI)
 	}
-
-	if teAuthMethod != nil {
-		if *teAuthMethod != "client_secret_basic" && *teAuthMethod != "client_secret_post" {
-			return nil, fmt.Errorf("unsupported 'token_endpoint_auth_method': %s", *teAuthMethod)
-		}
+	if requestConfig.Code != nil {
+		post.Set("code", *requestConfig.Code)
 	}
-
-	return &OAuth2Credentials{
-		ClientID:     clientID,
-		ClientSecret: clientSecret,
-		Scope:        scope,
-		// Backend is build up via config and token_endpoint will configure the backend,
-		// use the backend memory location here.
-		StorageKey:              fmt.Sprintf("%p|%s|%s", &oa.backend, clientID, clientSecret),
-		TokenEndpointAuthMethod: teAuthMethod,
-	}, nil
-}
-
-func (oa *OAuth2) newTokenRequest(ctx context.Context, creds *OAuth2Credentials) (*http.Request, error) {
-	post := url.Values{}
-	post.Set("grant_type", oa.config.GrantType)
-
-	if creds.Scope != nil {
-		post.Set("scope", *creds.Scope)
+	if requestConfig.CodeVerifier != nil {
+		post.Set("code_verifier", *requestConfig.CodeVerifier)
 	}
-	if creds.TokenEndpointAuthMethod != nil && *creds.TokenEndpointAuthMethod == "client_secret_post" {
-		post.Set("client_id", creds.ClientID)
-		post.Set("client_secret", creds.ClientSecret)
+	teAuthMethod := oa.config.GetTokenEndpointAuthMethod()
+	if teAuthMethod != nil && *teAuthMethod == "client_secret_post" {
+		post.Set("client_id", oa.config.GetClientID())
+		post.Set("client_secret", oa.config.GetClientSecret())
 	}
 
 	// url will be configured via backend roundtrip
@@ -188,67 +105,35 @@ func (oa *OAuth2) newTokenRequest(ctx context.Context, creds *OAuth2Credentials)
 
 	outreq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
-	if creds.TokenEndpointAuthMethod == nil || *creds.TokenEndpointAuthMethod == "client_secret_basic" {
-		auth := base64.StdEncoding.EncodeToString([]byte(creds.ClientID + ":" + creds.ClientSecret))
+	if teAuthMethod == nil || *teAuthMethod == "client_secret_basic" {
+		auth := base64.StdEncoding.EncodeToString([]byte(oa.config.GetClientID() + ":" + oa.config.GetClientSecret()))
 
 		outreq.Header.Set("Authorization", "Basic "+auth)
 	}
 
 	outCtx := context.WithValue(ctx, request.TokenRequest, "oauth2")
 
-	url, err := eval.GetContextAttribute(oa.config.Remain, outCtx, "token_endpoint")
-	if err != nil {
-		return nil, err
-	}
-
-	if url != "" {
-		outCtx = context.WithValue(outCtx, request.URLAttribute, url)
+	if tokenURL := oa.config.GetTokenEndpoint(); tokenURL != "" {
+		outCtx = context.WithValue(outCtx, request.URLAttribute, tokenURL)
 	}
 
 	return outreq.WithContext(outCtx), nil
 }
 
-func (oa *OAuth2) readAccessToken(data string) (string, error) {
+func ParseAccessToken(jsonBytes []byte) (map[string]interface{}, string, error) {
 	var jData map[string]interface{}
 
-	err := json.Unmarshal([]byte(data), &jData)
+	err := json.Unmarshal(jsonBytes, &jData)
 	if err != nil {
-		return "", err
+		return jData, "", err
 	}
 
 	var token string
 	if t, ok := jData["access_token"].(string); ok {
 		token = t
 	} else {
-		return "", fmt.Errorf("missing access token")
-	}
-
-	return token, nil
-}
-
-func (oa *OAuth2) updateAccessToken(jsonString, key string) (string, error) {
-	var jData map[string]interface{}
-
-	err := json.Unmarshal([]byte(jsonString), &jData)
-	if err != nil {
-		return "", err
-	}
-
-	var token string
-	if t, ok := jData["access_token"].(string); ok {
-		token = t
-	} else {
-		return "", fmt.Errorf("missing access token")
-	}
-
-	if oa.memStore != nil {
-		var ttl int64
-		if t, ok := jData["expires_in"].(float64); ok {
-			ttl = (int64)(t * 0.9)
-		}
-
-		oa.memStore.Set(key, jsonString, ttl)
+		return jData, "", fmt.Errorf("missing access token")
 	}
 
-	return token, nil
+	return jData, token, nil
 }
diff --git a/handler/transport/oauth2_req_auth.go b/handler/transport/oauth2_req_auth.go
new file mode 100644
index 00000000..c47d0a68
--- /dev/null
+++ b/handler/transport/oauth2_req_auth.go
@@ -0,0 +1,110 @@
+package transport
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/avenga/couper/cache"
+	"github.com/avenga/couper/config"
+	"github.com/avenga/couper/config/request"
+	"github.com/avenga/couper/errors"
+)
+
+var _ http.RoundTripper = &OAuth2ReqAuth{}
+
+// OAuth2ReqAuth represents the transport <OAuth2ReqAuth> object.
+type OAuth2ReqAuth struct {
+	oauth2   *OAuth2
+	config   *config.OAuth2ReqAuth
+	memStore *cache.MemoryStore
+	next     http.RoundTripper
+}
+
+// NewOAuth2ReqAuth creates a new <http.RoundTripper> object.
+func NewOAuth2ReqAuth(conf *config.OAuth2ReqAuth, memStore *cache.MemoryStore,
+	oauth2 *OAuth2, next http.RoundTripper) (http.RoundTripper, error) {
+	const grantType = "client_credentials"
+	if conf.GrantType != grantType {
+		return nil, errors.Backend.Label(conf.BackendName).Message("grant_type not supported: " + conf.GrantType)
+	}
+
+	return &OAuth2ReqAuth{
+		config:   conf,
+		oauth2:   oauth2,
+		memStore: memStore,
+		next:     next,
+	}, nil
+}
+
+// RoundTrip implements the <http.RoundTripper> interface.
+func (oa *OAuth2ReqAuth) RoundTrip(req *http.Request) (*http.Response, error) {
+	requestConfig, err := oa.oauth2.GetRequestConfig(req)
+	if err != nil {
+		return nil, errors.Backend.Label(oa.config.BackendName).With(err)
+	}
+
+	if data := oa.memStore.Get(requestConfig.StorageKey); data != "" {
+		token, terr := readAccessToken(data)
+		if terr != nil {
+			return nil, errors.Backend.Label(oa.config.BackendName).Message("token read error").With(terr)
+		}
+
+		req.Header.Set("Authorization", "Bearer "+token)
+
+		return oa.next.RoundTrip(req)
+	}
+
+	tokenResponse, err := oa.oauth2.RequestToken(req.Context(), requestConfig)
+
+	token, err := oa.updateAccessToken(tokenResponse, requestConfig.StorageKey)
+	if err != nil {
+		return nil, errors.Backend.Label(oa.config.BackendName).Message("token update error").With(err)
+	}
+
+	req.Header.Set("Authorization", "Bearer "+token)
+
+	res, err := oa.next.RoundTrip(req)
+
+	if res != nil && res.StatusCode == http.StatusUnauthorized {
+		oa.memStore.Del(requestConfig.StorageKey)
+
+		ctx := req.Context()
+		if retries, ok := ctx.Value(request.TokenRequestRetries).(uint8); !ok || retries < *oa.config.Retries {
+			ctx = context.WithValue(ctx, request.TokenRequestRetries, retries+1)
+
+			req.Header.Del("Authorization")
+			*req = *req.WithContext(ctx)
+
+			return oa.RoundTrip(req)
+		}
+	}
+
+	return res, err
+}
+
+func readAccessToken(data string) (string, error) {
+	_, token, err := ParseAccessToken([]byte(data))
+	if err != nil {
+		return "", err
+	}
+
+	return token, nil
+}
+
+func (oa *OAuth2ReqAuth) updateAccessToken(jsonBytes []byte, key string) (string, error) {
+	jData, token, err := ParseAccessToken(jsonBytes)
+	if err != nil {
+		return "", err
+	}
+
+	if oa.memStore != nil {
+		var ttl int64
+		if t, ok := jData["expires_in"].(float64); ok {
+			ttl = (int64)(t * 0.9)
+		}
+
+		oa.memStore.Set(key, string(jsonBytes), ttl)
+	}
+
+	return token, nil
+}
diff --git a/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/.gitignore b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/.gitignore
new file mode 100644
index 00000000..a1338d68
--- /dev/null
+++ b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/.gitignore
@@ -0,0 +1,14 @@
+# Binaries for programs and plugins
+*.exe
+*.dll
+*.so
+*.dylib
+
+# Test binary, build with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
+.glide/
diff --git a/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/LICENSE b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/LICENSE
new file mode 100644
index 00000000..7938a660
--- /dev/null
+++ b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 nirasan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/README.md b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/README.md
new file mode 100644
index 00000000..a758493d
--- /dev/null
+++ b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/README.md
@@ -0,0 +1,18 @@
+# go-oauth-pkce-code-verifier
+[OAuth PKCE](https://tools.ietf.org/html/rfc7636) code_verifier and code_challenge generator implimented golang.
+
+# How to use
+
+```go
+// Create code_verifier
+v := CreateCodeVerifier()
+code_verifier := v.String()
+
+// Create code_challenge with plain method
+code_challenge := v.CodeChallengeS256()
+code_challenge_method := "plain"
+
+// Create code_challenge with S256 method
+code_challenge := v.CodeChallengeS256()
+code_challenge_method := "S256"
+```
\ No newline at end of file
diff --git a/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/verifier.go b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/verifier.go
new file mode 100644
index 00000000..f188b86b
--- /dev/null
+++ b/vendor/github.com/jimlambrt/go-oauth-pkce-code-verifier/verifier.go
@@ -0,0 +1,87 @@
+package go_oauth_pkce_code_verifier
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"fmt"
+	"io"
+	"strings"
+)
+
+const (
+	DefaultLength = 32
+	MinLength     = 32
+	MaxLength     = 96
+)
+
+type CodeVerifier struct {
+	Value string
+}
+
+func CreateCodeVerifier() (*CodeVerifier, error) {
+	return CreateCodeVerifierWithLength(DefaultLength)
+}
+
+func CreateCodeVerifierWithLength(length int) (*CodeVerifier, error) {
+	if length < MinLength || length > MaxLength {
+		return nil, fmt.Errorf("invalid length: %v", length)
+	}
+	buf, err := randomBytes(length)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate random bytes: %v", err)
+	}
+	return CreateCodeVerifierFromBytes(buf)
+}
+
+func CreateCodeVerifierFromBytes(b []byte) (*CodeVerifier, error) {
+	return &CodeVerifier{
+		Value: encode(b),
+	}, nil
+}
+
+func (v *CodeVerifier) String() string {
+	return v.Value
+}
+
+func (v *CodeVerifier) CodeChallengePlain() string {
+	return v.Value
+}
+
+func (v *CodeVerifier) CodeChallengeS256() string {
+	h := sha256.New()
+	h.Write([]byte(v.Value))
+	return encode(h.Sum(nil))
+}
+
+func encode(msg []byte) string {
+	encoded := base64.StdEncoding.EncodeToString(msg)
+	encoded = strings.Replace(encoded, "+", "-", -1)
+	encoded = strings.Replace(encoded, "/", "_", -1)
+	encoded = strings.Replace(encoded, "=", "", -1)
+	return encoded
+}
+
+// https://tools.ietf.org/html/rfc7636#section-4.1)
+func randomBytes(length int) ([]byte, error) {
+	const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+	const csLen = byte(len(charset))
+	output := make([]byte, 0, length)
+	for {
+		buf := make([]byte, length)
+		if _, err := io.ReadFull(rand.Reader, buf); err != nil {
+			return nil, fmt.Errorf("failed to read random bytes: %v", err)
+		}
+		for _, b := range buf {
+			// Avoid bias by using a value range that's a multiple of 62
+			if b < (csLen * 4) {
+				output = append(output, charset[b%csLen])
+
+				if len(output) == length {
+					return output, nil
+				}
+			}
+		}
+	}
+
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 2705046f..24eefccc 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -42,6 +42,9 @@ github.com/hashicorp/hcl/v2/hclsyntax
 github.com/hashicorp/hcl/v2/hcltest
 github.com/hashicorp/hcl/v2/hclwrite
 github.com/hashicorp/hcl/v2/json
+# github.com/jimlambrt/go-oauth-pkce-code-verifier v0.0.0-20201220003123-6363600dffda
+## explicit
+github.com/jimlambrt/go-oauth-pkce-code-verifier
 # github.com/jonboulle/clockwork v0.2.0
 github.com/jonboulle/clockwork
 # github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e


Test patch:
diff --git a/config/configload/merged_test.go b/config/configload/merged_test.go
index a767a3af..e1fdf71e 100644
--- a/config/configload/merged_test.go
+++ b/config/configload/merged_test.go
@@ -16,7 +16,7 @@ import (
 
 func TestMergeBodies(t *testing.T) {
 	type expectedBody struct {
-		OAuth2 *config.OAuth2 `hcl:"oauth2,block"`
+		OAuth2 *config.OAuth2ReqAuth `hcl:"oauth2,block"`
 	}
 
 	type container struct {
@@ -24,11 +24,11 @@ func TestMergeBodies(t *testing.T) {
 	}
 
 	bodies := &container{[]*expectedBody{
-		{OAuth2: &config.OAuth2{
+		{OAuth2: &config.OAuth2ReqAuth{
 			BackendName: "test",
 			GrantType:   "override_me",
 		}},
-		{OAuth2: &config.OAuth2{
+		{OAuth2: &config.OAuth2ReqAuth{
 			GrantType: "no_creds",
 		}},
 	}}
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 3914757d..4f4ccfa4 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -20,11 +20,13 @@ import (
 	"strings"
 	"sync"
 	"testing"
+	"text/template"
 	"time"
 
 	"github.com/sirupsen/logrus"
 	logrustest "github.com/sirupsen/logrus/hooks/test"
 
+	"github.com/avenga/couper/accesscontrol"
 	"github.com/avenga/couper/command"
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/configload"
@@ -81,6 +83,27 @@ func newCouper(file string, helper *test.Helper) (func(), *logrustest.Hook) {
 	return newCouperWithConfig(couperConfig, helper)
 }
 
+// newCouperWithTemplate applies given variables first and loads Couper with the resulting configuration file.
+// Example template:
+// 		My {{.message}}
+// Example value:
+//		map[string]interface{}{
+//			"message": "value",
+//		}
+func newCouperWithTemplate(file string, helper *test.Helper, vars map[string]interface{}) (func(), *logrustest.Hook) {
+	if vars == nil {
+		return newCouper(file, helper)
+	}
+
+	tpl, err := template.New(filepath.Base(file)).ParseFiles(file)
+	helper.Must(err)
+
+	result := &bytes.Buffer{}
+	helper.Must(tpl.Execute(result, vars))
+
+	return newCouperWithBytes(result.Bytes(), helper)
+}
+
 func newCouperWithBytes(file []byte, helper *test.Helper) (func(), *logrustest.Hook) {
 	couperConfig, err := configload.LoadBytes(file, "couper-bytes.hcl")
 	helper.Must(err)
@@ -2612,3 +2635,183 @@ func TestLog_Level(t *testing.T) {
 		}
 	}
 }
+
+func TestOAuthPKCEFunctions(t *testing.T) {
+	client := newClient()
+
+	shutdown, _ := newCouper("testdata/integration/functions/02_couper.hcl", test.New(t))
+	defer shutdown()
+
+	helper := test.New(t)
+
+	req, err := http.NewRequest(http.MethodGet, "http://example.com:8080/pkce-ok", nil)
+	helper.Must(err)
+
+	res, err := client.Do(req)
+	helper.Must(err)
+
+	if res.StatusCode != 200 {
+		t.Errorf("expected Status %d, got: %d", 200, res.StatusCode)
+		return
+	}
+
+	cv1 := res.Header.Get("x-cv-1")
+	cv2 := res.Header.Get("x-cv-2")
+	ccp := res.Header.Get("x-cc-plain")
+	ccs := res.Header.Get("x-cc-s256")
+	if cv2 != cv1 {
+		t.Errorf("multiple calls to oauth_code_verifier() must return the same value:\n\t%s\n\t%s", cv1, cv2)
+	}
+	if ccp != cv1 {
+		t.Errorf("call to oauth_code_challenge(\"plain\") must return the same value as call to oauth_code_verifier():\n\t%s\n\t%s", ccp, cv1)
+	}
+	s256 := accesscontrol.Base64urlSha256(cv1)
+	if ccs != s256 {
+		t.Errorf("call to oauth_code_challenge(\"S256\") returns wrong value:\nactual:\t\t%s\nexpected:\t%s", ccs, s256)
+	}
+	au, err := url.Parse(res.Header.Get("x-au-pkce"))
+	helper.Must(err)
+	auq := au.Query()
+	if auq.Get("response_type") != "code" {
+		t.Errorf("oauth_authorization_url(): wrong response_type query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("response_type"), "code")
+	}
+	if auq.Get("redirect_uri") != "http://localhost:8085/oidc/callback" {
+		t.Errorf("oauth_authorization_url(): wrong redirect_uri query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("redirect_uri"), "http://localhost:8085/oidc/callback")
+	}
+	if auq.Get("scope") != "openid profile email" {
+		t.Errorf("oauth_authorization_url(): wrong scope query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("scope"), "openid profile email")
+	}
+	if auq.Get("code_challenge_method") != "S256" {
+		t.Errorf("oauth_authorization_url(): wrong code_challenge_method:\nactual:\t\t%s\nexpected:\t%s", auq.Get("code_challenge_method"), "S256")
+	}
+	if auq.Get("code_challenge") != ccs {
+		t.Errorf("oauth_authorization_url(): wrong code_challenge:\nactual:\t\t%s\nexpected:\t%s", auq.Get("code_challenge"), ccs)
+	}
+	if auq.Get("state") != "" {
+		t.Errorf("oauth_authorization_url(): wrong state:\nactual:\t\t%s\nexpected:\t%s", auq.Get("state"), "")
+	}
+	if auq.Get("nonce") != "" {
+		t.Errorf("oauth_authorization_url(): wrong nonce:\nactual:\t\t%s\nexpected:\t%s", auq.Get("nonce"), "")
+	}
+	if auq.Get("client_id") != "foo" {
+		t.Errorf("oauth_authorization_url(): wrong client_id:\nactual:\t\t%s\nexpected:\t%s", auq.Get("client_id"), "foo")
+	}
+
+	req, err = http.NewRequest(http.MethodGet, "http://example.com:8080/pkce-ok", nil)
+	helper.Must(err)
+
+	res, err = client.Do(req)
+	helper.Must(err)
+
+	cv1_n := res.Header.Get("x-cv-1")
+	if cv1_n == cv1 {
+		t.Errorf("calls to oauth_code_verifier() on different requests must not return the same value:\n\t%s\n\t%s", cv1, cv1_n)
+	}
+
+	req, err = http.NewRequest(http.MethodGet, "http://example.com:8080/pkce-nok", nil)
+	helper.Must(err)
+
+	res, err = client.Do(req)
+	helper.Must(err)
+
+	if res.StatusCode != 500 {
+		t.Errorf("/pkce-nok: expected Status %d, got: %d", 500, res.StatusCode)
+		return
+	}
+}
+
+func TestOAuthCSRFFunctions(t *testing.T) {
+	client := newClient()
+
+	shutdown, _ := newCouper("testdata/integration/functions/02_couper.hcl", test.New(t))
+	defer shutdown()
+
+	helper := test.New(t)
+
+	req, err := http.NewRequest(http.MethodGet, "http://example.com:8080/pkce-ok", nil)
+	helper.Must(err)
+
+	res, err := client.Do(req)
+	helper.Must(err)
+
+	if res.StatusCode != 200 {
+		t.Errorf("expected Status %d, got: %d", 200, res.StatusCode)
+		return
+	}
+
+	ct1 := res.Header.Get("x-ct-1")
+	ct2 := res.Header.Get("x-ct-2")
+	cht := res.Header.Get("x-cht")
+	if ct2 != ct1 {
+		t.Errorf("multiple calls to oauth_csrf_token() must return the same value:\n\t%s\n\t%s", ct1, ct2)
+	}
+	s256 := accesscontrol.Base64urlSha256(ct1)
+	if cht != s256 {
+		t.Errorf("call to oauth_hashed_csrf_token() returns wrong value:\n\tactual: %s\n\texpected: %s", cht, s256)
+	}
+	au, err := url.Parse(res.Header.Get("x-au-state"))
+	helper.Must(err)
+	auq := au.Query()
+	if auq.Get("response_type") != "code" {
+		t.Errorf("oauth_authorization_url(): wrong response_type query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("response_type"), "code")
+	}
+	if auq.Get("redirect_uri") != "http://localhost:8085/oidc/callback" {
+		t.Errorf("oauth_authorization_url(): wrong redirect_uri query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("redirect_uri"), "http://localhost:8085/oidc/callback")
+	}
+	if auq.Get("scope") != "openid profile" {
+		t.Errorf("oauth_authorization_url(): wrong scope query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("scope"), "openid profile")
+	}
+	if auq.Get("code_challenge_method") != "" {
+		t.Errorf("oauth_authorization_url(): wrong code_challenge_method:\nactual:\t\t%s\nexpected:\t%s", auq.Get("code_challenge_method"), "")
+	}
+	if auq.Get("code_challenge") != "" {
+		t.Errorf("oauth_authorization_url(): wrong code_challenge:\nactual:\t\t%s\nexpected:\t%s", auq.Get("code_challenge"), "")
+	}
+	if auq.Get("state") != cht {
+		t.Errorf("oauth_authorization_url(): wrong state:\nactual:\t\t%s\nexpected:\t%s", auq.Get("state"), cht)
+	}
+	if auq.Get("nonce") != "" {
+		t.Errorf("oauth_authorization_url(): wrong nonce:\nactual:\t\t%s\nexpected:\t%s", auq.Get("nonce"), "")
+	}
+	if auq.Get("client_id") != "foo" {
+		t.Errorf("oauth_authorization_url(): wrong client_id:\nactual:\t\t%s\nexpected:\t%s", auq.Get("client_id"), "foo")
+	}
+	au, err = url.Parse(res.Header.Get("x-au-nonce"))
+	helper.Must(err)
+	auq = au.Query()
+	if auq.Get("response_type") != "code" {
+		t.Errorf("oauth_authorization_url(): wrong response_type query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("response_type"), "code")
+	}
+	if auq.Get("redirect_uri") != "http://localhost:8085/oidc/callback" {
+		t.Errorf("oauth_authorization_url(): wrong redirect_uri query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("redirect_uri"), "http://localhost:8085/oidc/callback")
+	}
+	if auq.Get("scope") != "openid profile" {
+		t.Errorf("oauth_authorization_url(): wrong scope query param:\nactual:\t\t%s\nexpected:\t%s", auq.Get("scope"), "openid profile")
+	}
+	if auq.Get("code_challenge_method") != "" {
+		t.Errorf("oauth_authorization_url(): wrong code_challenge_method:\nactual:\t\t%s\nexpected:\t%s", auq.Get("code_challenge_method"), "")
+	}
+	if auq.Get("code_challenge") != "" {
+		t.Errorf("oauth_authorization_url(): wrong code_challenge:\nactual:\t\t%s\nexpected:\t%s", auq.Get("code_challenge"), "")
+	}
+	if auq.Get("state") != "" {
+		t.Errorf("oauth_authorization_url(): wrong nonce:\nactual:\t\t%s\nexpected:\t%s", auq.Get("state"), "")
+	}
+	if auq.Get("nonce") != cht {
+		t.Errorf("oauth_authorization_url(): wrong state:\nactual:\t\t%s\nexpected:\t%s", auq.Get("nonce"), cht)
+	}
+	if auq.Get("client_id") != "foo" {
+		t.Errorf("oauth_authorization_url(): wrong client_id:\nactual:\t\t%s\nexpected:\t%s", auq.Get("client_id"), "foo")
+	}
+
+	req, err = http.NewRequest(http.MethodGet, "http://example.com:8080/pkce-ok", nil)
+	helper.Must(err)
+
+	res, err = client.Do(req)
+	helper.Must(err)
+
+	ct1_n := res.Header.Get("x-ct-1")
+	if ct1_n == ct1 {
+		t.Errorf("calls to oauth_csrf_token() on different requests must not return the same value:\n\t%s\n\t%s", ct1, ct1_n)
+	}
+}
diff --git a/server/http_oauth2_test.go b/server/http_oauth2_test.go
index dfb2bb8b..04d831f2 100644
--- a/server/http_oauth2_test.go
+++ b/server/http_oauth2_test.go
@@ -1,13 +1,19 @@
 package server_test
 
 import (
+	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 	"time"
 
+	"github.com/dgrijalva/jwt-go/v4"
+
+	"github.com/avenga/couper/accesscontrol"
+	"github.com/avenga/couper/eval/lib"
 	"github.com/avenga/couper/internal/test"
 )
 
@@ -67,7 +73,7 @@ func TestEndpoints_OAuth2(t *testing.T) {
 		defer ResourceOrigin.Close()
 
 		confPath := fmt.Sprintf("testdata/oauth2/%d_retries_couper.hcl", i)
-		shutdown, hook := newCouper(confPath, test.New(t))
+		shutdown, hook := newCouperWithTemplate(confPath, test.New(t), map[string]interface{}{"asOrigin": oauthOrigin.URL, "rsOrigin": ResourceOrigin.URL})
 		defer func() {
 			if t.Failed() {
 				for _, e := range hook.Entries {
@@ -80,9 +86,6 @@ func TestEndpoints_OAuth2(t *testing.T) {
 		req, err := http.NewRequest(http.MethodGet, "http://anyserver:8080/", nil)
 		helper.Must(err)
 
-		req.Header.Set("X-Token-Endpoint", oauthOrigin.URL)
-		req.Header.Set("X-Origin", ResourceOrigin.URL)
-
 		for _, p := range []string{"/", "/2nd"} {
 			hook.Reset()
 
@@ -163,7 +166,7 @@ func TestEndpoints_OAuth2_Options(t *testing.T) {
 		defer oauthOrigin.Close()
 
 		confPath := fmt.Sprintf("testdata/oauth2/%s", tc.configFile)
-		shutdown, hook := newCouper(confPath, test.New(t))
+		shutdown, hook := newCouperWithTemplate(confPath, test.New(t), map[string]interface{}{"asOrigin": oauthOrigin.URL})
 		defer func() {
 			if t.Failed() {
 				for _, e := range hook.Entries {
@@ -176,8 +179,6 @@ func TestEndpoints_OAuth2_Options(t *testing.T) {
 		req, err := http.NewRequest(http.MethodGet, "http://anyserver:8080/", nil)
 		helper.Must(err)
 
-		req.Header.Set("X-Token-Endpoint", oauthOrigin.URL)
-
 		hook.Reset()
 
 		tokenSeenCh = make(chan struct{})
@@ -197,3 +198,177 @@ func TestEndpoints_OAuth2_Options(t *testing.T) {
 		shutdown()
 	}
 }
+
+func TestOAuth2AccessControl(t *testing.T) {
+	client := newClient()
+	helper := test.New(t)
+
+	st := "qeirtbnpetrbi"
+	state := accesscontrol.Base64urlSha256(st)
+
+	oauthOrigin := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		if req.URL.Path == "/token" {
+			_ = req.ParseForm()
+			rw.Header().Set("Content-Type", "application/json")
+			rw.WriteHeader(http.StatusOK)
+
+			code := req.PostForm.Get("code")
+			idTokenToAdd := ""
+			if strings.HasSuffix(code, "-id") {
+				nonce := state
+				mapClaims := jwt.MapClaims{"aud": []string{"foo", "another-client-id"}}
+				if !strings.HasSuffix(code, "-miss-id") {
+					if strings.HasSuffix(code, "-wiss-id") {
+						mapClaims["iss"] = "https://malicious.authorization.server"
+					} else {
+						mapClaims["iss"] = "https://authorization.server"
+					}
+				}
+				if !strings.HasSuffix(code, "-miat-id") {
+					// 1970-01-01 00:16:40 +0000 UTC
+					mapClaims["iat"] = 1000
+				}
+				if !strings.HasSuffix(code, "-mexp-id") {
+					// 2096-10-02 07:06:40 +0000 UTC
+					mapClaims["exp"] = 4000000000
+				}
+				if !strings.HasSuffix(code, "-msub-id") {
+					if strings.HasSuffix(code, "-wsub-id") {
+						mapClaims["sub"] = "me"
+					} else {
+						mapClaims["sub"] = "myself"
+					}
+				}
+				if strings.HasSuffix(code, "-waud-id") {
+					mapClaims["aud"] = "another-client-id"
+				}
+				if strings.HasSuffix(code, "-wazp-id") {
+					mapClaims["azp"] = "bar"
+				} else if !strings.HasSuffix(code, "-mazp-id") {
+					mapClaims["azp"] = "foo"
+				}
+				if strings.HasSuffix(code, "-wn-id") {
+					nonce = nonce + "-wrong"
+				}
+				if !strings.HasSuffix(code, "-mn-id") {
+					mapClaims["nonce"] = nonce
+				}
+				idToken, _ := lib.CreateJWT("HS256", []byte("$e(rEt"), mapClaims)
+				idTokenToAdd = `"id_token":"` + idToken + `",
+				`
+			}
+
+			body := []byte(`{
+				"access_token": "abcdef0123456789",
+				"token_type": "bearer",
+				"expires_in": 100,
+				` + idTokenToAdd +
+				`"form_params": "` + req.PostForm.Encode() + `",
+				"authorization": "` + req.Header.Get("Authorization") + `"
+			}`)
+			_, werr := rw.Write(body)
+			helper.Must(werr)
+
+			return
+		} else if req.URL.Path == "/userinfo" {
+			body := []byte(`{"sub": "myself"}`)
+			_, werr := rw.Write(body)
+			helper.Must(werr)
+
+			return
+		}
+		rw.WriteHeader(http.StatusBadRequest)
+	}))
+	defer oauthOrigin.Close()
+
+	type testCase struct {
+		name          string
+		filename      string
+		path          string
+		header        http.Header
+		status        int
+		params        string
+		authorization string
+		wantErrLog    string
+	}
+
+	for _, tc := range []testCase{
+		{"no code, but error", "04_couper.hcl", "/cb?error=qeuboub", http.Header{}, http.StatusForbidden, "", "", "access control error: ac: missing code query parameter; query=\"error=qeuboub\""},
+		{"no code; error handler", "05_couper.hcl", "/cb?error=qeuboub", http.Header{"Cookie": []string{"pkcecv=qerbnr"}}, http.StatusBadRequest, "", "", "access control error: ac: missing code query parameter; query=\"error=qeuboub\""},
+		{"code, missing state param", "06_couper.hcl", "/cb?code=qeuboub", http.Header{"Cookie": []string{"st=qerbnr"}}, http.StatusForbidden, "", "", "access control error: ac: missing state query parameter; query=\"code=qeuboub\""},
+		{"code, wrong state param", "06_couper.hcl", "/cb?code=qeuboub&state=wrong", http.Header{"Cookie": []string{"st=" + st}}, http.StatusForbidden, "", "", "access control error: ac: CSRF token mismatch: \"wrong\" (from query param) vs. \"qeirtbnpetrbi\" (s256: \"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\")"},
+		{"code, state param, wrong CSRF token", "06_couper.hcl", "/cb?code=qeuboub&state=" + state, http.Header{"Cookie": []string{"st=" + st + "-wrong"}}, http.StatusForbidden, "", "", "access control error: ac: CSRF token mismatch: \"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\" (from query param) vs. \"qeirtbnpetrbi-wrong\" (s256: \"Mj0ecDMNNzOwqUt1iFlY8TOTTKa17ISo8ARgt0pyb1A\")"},
+		{"code, state param, missing CSRF token", "06_couper.hcl", "/cb?code=qeuboub&state=" + state, http.Header{}, http.StatusForbidden, "", "", "access control error: ac: Empty CSRF token_value"},
+		{"code, missing nonce", "07_couper.hcl", "/cb?code=qeuboub-mn-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: missing nonce claim in ID token, claims='jwt.MapClaims{\"aud\":[]interface {}{\"foo\", \"another-client-id\"}, \"azp\":\"foo\", \"exp\":4e+09, \"iat\":1000, \"iss\":\"https://authorization.server\", \"sub\":\"myself\"}'"},
+		{"code, wrong nonce", "07_couper.hcl", "/cb?code=qeuboub-wn-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: CSRF token mismatch: \"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ-wrong\" (from nonce claim) vs. \"qeirtbnpetrbi\" (s256: \"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\")"},
+		{"code, nonce, wrong CSRF token", "07_couper.hcl", "/cb?code=qeuboub-id", http.Header{"Cookie": []string{"nnc=" + st + "-wrong"}}, http.StatusForbidden, "", "", "access control error: ac: CSRF token mismatch: \"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\" (from nonce claim) vs. \"qeirtbnpetrbi-wrong\" (s256: \"Mj0ecDMNNzOwqUt1iFlY8TOTTKa17ISo8ARgt0pyb1A\")"},
+		{"code, nonce, missing CSRF token", "07_couper.hcl", "/cb?code=qeuboub-id", http.Header{}, http.StatusForbidden, "", "", "access control error: ac: Empty CSRF token_value"},
+		{"code, missing sub claim", "07_couper.hcl", "/cb?code=qeuboub-msub-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: missing sub claim in ID token, claims='jwt.MapClaims{\"aud\":[]interface {}{\"foo\", \"another-client-id\"}, \"azp\":\"foo\", \"exp\":4e+09, \"iat\":1000, \"iss\":\"https://authorization.server\", \"nonce\":\"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\"}'"},
+		{"code, sub mismatch", "07_couper.hcl", "/cb?code=qeuboub-wsub-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: subject mismatch, in ID token \"me\", in userinfo response \"myself\""},
+		{"code, missing exp claim", "07_couper.hcl", "/cb?code=qeuboub-mexp-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: missing exp claim in ID token, claims='jwt.MapClaims{\"aud\":[]interface {}{\"foo\", \"another-client-id\"}, \"azp\":\"foo\", \"iat\":1000, \"iss\":\"https://authorization.server\", \"nonce\":\"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\", \"sub\":\"myself\"}'"},
+		{"code, missing iat claim", "07_couper.hcl", "/cb?code=qeuboub-miat-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: missing iat claim in ID token, claims='jwt.MapClaims{\"aud\":[]interface {}{\"foo\", \"another-client-id\"}, \"azp\":\"foo\", \"exp\":4e+09, \"iss\":\"https://authorization.server\", \"nonce\":\"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\", \"sub\":\"myself\"}'"},
+		{"code, missing azp claim", "07_couper.hcl", "/cb?code=qeuboub-mazp-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: missing azp claim in ID token, claims='jwt.MapClaims{\"aud\":[]interface {}{\"foo\", \"another-client-id\"}, \"exp\":4e+09, \"iat\":1000, \"iss\":\"https://authorization.server\", \"nonce\":\"oUuoMU0RFWI5itMBnMTt_TJ4SxxgE96eZFMNXSl63xQ\", \"sub\":\"myself\"}'"},
+		{"code, wrong azp claim", "07_couper.hcl", "/cb?code=qeuboub-wazp-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: azp claim / client ID mismatch, azp = \"bar\", client ID = \"foo\""},
+		{"code, missing iss claim", "07_couper.hcl", "/cb?code=qeuboub-miss-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: token issuer is invalid: 'iss' value doesn't match expectation"},
+		{"code, wrong iss claim", "07_couper.hcl", "/cb?code=qeuboub-wiss-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: token issuer is invalid: 'iss' value doesn't match expectation"},
+		{"code, wrong aud claim", "07_couper.hcl", "/cb?code=qeuboub-waud-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusForbidden, "", "", "access control error: ac: token audience is invalid: 'foo' wasn't found in aud claim"},
+		{"code; client_secret_basic; PKCE", "04_couper.hcl", "/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", "/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", "/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=", ""},
+		{"code, nonce param", "07_couper.hcl", "/cb?code=qeuboub-id", http.Header{"Cookie": []string{"nnc=" + st}}, http.StatusOK, "code=qeuboub-id&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcb", "Basic Zm9vOmV0YmluYnA0aW4=", ""},
+	} {
+		t.Run(tc.path[1:], func(subT *testing.T) {
+			shutdown, hook := newCouperWithTemplate("testdata/oauth2/"+tc.filename, test.New(t), map[string]interface{}{"asOrigin": oauthOrigin.URL})
+			defer shutdown()
+
+			helper := test.New(subT)
+
+			req, err := http.NewRequest(http.MethodGet, "http://back.end:8080"+tc.path, nil)
+			helper.Must(err)
+
+			for k, v := range tc.header {
+				req.Header.Set(k, v[0])
+			}
+
+			res, err := client.Do(req)
+			helper.Must(err)
+
+			if res.StatusCode != tc.status {
+				subT.Errorf("%q: expected Status %d, got: %d", tc.name, tc.status, res.StatusCode)
+			}
+
+			tokenResBytes, err := ioutil.ReadAll(res.Body)
+			var jData map[string]interface{}
+			json.Unmarshal(tokenResBytes, &jData)
+			if params, ok := jData["form_params"]; ok {
+				if params != tc.params {
+					subT.Errorf("%q: expected params %s, got: %s", tc.name, tc.params, params)
+				}
+			} else {
+				if "" != tc.params {
+					subT.Errorf("%q: expected params %s, got no", tc.name, tc.params)
+				}
+			}
+			if authorization, ok := jData["authorization"]; ok {
+				if authorization != tc.authorization {
+					subT.Errorf("%q: expected authorization %s, got: %s", tc.name, tc.authorization, authorization)
+				}
+			} else {
+				if "" != tc.authorization {
+					subT.Errorf("%q: expected authorization %s, got no", tc.name, tc.authorization)
+				}
+			}
+
+			message := getAccessControlMessages(hook)
+			if tc.wantErrLog == "" {
+				if message != "" {
+					subT.Errorf("%q: Expected error log: %q, actual: %#v", tc.name, tc.wantErrLog, message)
+				}
+			} else {
+				if !strings.HasPrefix(message, tc.wantErrLog) {
+					subT.Errorf("%q: Expected error log message: %q, actual: %#v", tc.name, tc.wantErrLog, message)
+				}
+			}
+		})
+	}
+}
diff --git a/server/testdata/integration/functions/02_couper.hcl b/server/testdata/integration/functions/02_couper.hcl
new file mode 100644
index 00000000..b385a483
--- /dev/null
+++ b/server/testdata/integration/functions/02_couper.hcl
@@ -0,0 +1,66 @@
+server "oauth-functions" {
+  endpoint "/pkce-ok" {
+    response {
+      headers = {
+        x-cv-1 = beta_oauth_code_verifier()
+        x-cv-2 = beta_oauth_code_verifier()
+        x-cc-plain = beta_oauth_code_challenge("plain")
+        x-cc-s256 = beta_oauth_code_challenge("S256")
+        x-ct-1 = beta_oauth_csrf_token()
+        x-ct-2 = beta_oauth_csrf_token()
+        x-cht = beta_oauth_hashed_csrf_token()
+        x-au-pkce = beta_oauth_authorization_url("ac-pkce")
+        x-au-state = beta_oauth_authorization_url("ac-state")
+        x-au-nonce = beta_oauth_authorization_url("ac-nonce")
+      }
+    }
+  }
+  endpoint "/pkce-nok" {
+    response {
+      headers = {
+        x-cc-nok = beta_oauth_code_challenge("nok")
+      }
+    }
+  }
+}
+definitions {
+  beta_oauth2 "ac-pkce" {
+    grant_type = "authorization_code"
+    authorization_endpoint = "https://authorization.server/oauth/authorize"
+    scope = "openid profile email"
+    token_endpoint = "https://authorization.server/oauth/token"
+    redirect_uri = "http://localhost:8085/oidc/callback"
+    client_id = "foo"
+    client_secret = "5eCr3t"
+    pkce {
+      code_challenge_method = "S256"
+      code_verifier_value = "not_used_here"
+    }
+  }
+  beta_oauth2 "ac-state" {
+    grant_type = "authorization_code"
+    authorization_endpoint = "https://authorization.server/oauth/authorize"
+    scope = "openid profile"
+    token_endpoint = "https://authorization.server/oauth/token"
+    redirect_uri = "http://localhost:8085/oidc/callback"
+    client_id = "foo"
+    client_secret = "5eCr3t"
+    csrf {
+      token_param = "state"
+      token_value = "not_used_here"
+    }
+  }
+  beta_oauth2 "ac-nonce" {
+    grant_type = "authorization_code"
+    authorization_endpoint = "https://authorization.server/oauth/authorize"
+    scope = "openid profile"
+    token_endpoint = "https://authorization.server/oauth/token"
+    redirect_uri = "http://localhost:8085/oidc/callback"
+    client_id = "foo"
+    client_secret = "5eCr3t"
+    csrf {
+      token_param = "nonce"
+      token_value = "not_used_here"
+    }
+  }
+}
diff --git a/server/testdata/oauth2/01_couper.hcl b/server/testdata/oauth2/01_couper.hcl
index 7bc2d4e6..4421305a 100644
--- a/server/testdata/oauth2/01_couper.hcl
+++ b/server/testdata/oauth2/01_couper.hcl
@@ -1,8 +1,8 @@
 server "oauth2-options" {
-  error_file = "./../integration/server_error.html"
+  error_file = "./testdata/integration/server_error.html"
 
   api {
-    error_file = "./../integration/api_error.json"
+    error_file = "./testdata/integration/api_error.json"
 
     endpoint "/" {
       proxy {
@@ -10,7 +10,7 @@ server "oauth2-options" {
           url = "https://example.com/"
 
           oauth2 {
-            token_endpoint = "${request.headers.x-token-endpoint}/options"
+            token_endpoint = "{{.asOrigin}}/options"
             retries        = 0
             client_id      = "user"
             client_secret  = "pass word"
diff --git a/server/testdata/oauth2/02_couper.hcl b/server/testdata/oauth2/02_couper.hcl
index 1f4cbcc2..5f6ad2d8 100644
--- a/server/testdata/oauth2/02_couper.hcl
+++ b/server/testdata/oauth2/02_couper.hcl
@@ -1,8 +1,8 @@
 server "oauth2-options" {
-  error_file = "./../integration/server_error.html"
+  error_file = "./testdata/integration/server_error.html"
 
   api {
-    error_file = "./../integration/api_error.json"
+    error_file = "./testdata/integration/api_error.json"
 
     endpoint "/" {
       proxy {
@@ -10,7 +10,7 @@ server "oauth2-options" {
           url = "https://example.com/"
 
           oauth2 {
-            token_endpoint = "${request.headers.x-token-endpoint}/options"
+            token_endpoint = "{{.asOrigin}}/options"
             retries        = 0
             client_id      = "user"
             client_secret  = "pass"
diff --git a/server/testdata/oauth2/03_couper.hcl b/server/testdata/oauth2/03_couper.hcl
index 6d31920d..c0391988 100644
--- a/server/testdata/oauth2/03_couper.hcl
+++ b/server/testdata/oauth2/03_couper.hcl
@@ -1,8 +1,8 @@
 server "oauth2-options" {
-  error_file = "./../integration/server_error.html"
+  error_file = "./testdata/integration/server_error.html"
 
   api {
-    error_file = "./../integration/api_error.json"
+    error_file = "./testdata/integration/api_error.json"
 
     endpoint "/" {
       proxy {
@@ -10,7 +10,7 @@ server "oauth2-options" {
           url = "https://example.com/"
 
           oauth2 {
-            token_endpoint = "${request.headers.x-token-endpoint}/options"
+            token_endpoint = "{{.asOrigin}}/options"
             retries        = 0
             client_id      = "user"
             client_secret  = "pass"
diff --git a/server/testdata/oauth2/04_couper.hcl b/server/testdata/oauth2/04_couper.hcl
new file mode 100644
index 00000000..93601c15
--- /dev/null
+++ b/server/testdata/oauth2/04_couper.hcl
@@ -0,0 +1,24 @@
+server "client" {
+  api {
+    endpoint "/cb" {
+      access_control = ["ac"]
+      response {
+        json_body = request.context.ac
+      }
+    }
+  }
+}
+definitions {
+  beta_oauth2 "ac" {
+    grant_type = "authorization_code"
+    redirect_uri = "http://localhost:8080/cb" # value is not checked
+    authorization_endpoint = "https://authorization.server/oauth2/authorize"
+    token_endpoint = "{{.asOrigin}}/token"
+    client_id = "foo"
+    client_secret = "etbinbp4in"
+    pkce {
+      code_challenge_method = "S256"
+      code_verifier_value = request.cookies.pkcecv
+    }
+  }
+}
diff --git a/server/testdata/oauth2/05_couper.hcl b/server/testdata/oauth2/05_couper.hcl
new file mode 100644
index 00000000..2a828cd0
--- /dev/null
+++ b/server/testdata/oauth2/05_couper.hcl
@@ -0,0 +1,30 @@
+server "client" {
+  api {
+    endpoint "/cb" {
+      access_control = ["ac"]
+      response {
+        json_body = request.context.ac
+      }
+    }
+  }
+}
+definitions {
+  beta_oauth2 "ac" {
+    grant_type = "authorization_code"
+    redirect_uri = "http://localhost:8080/cb" # value is not checked
+    authorization_endpoint = "https://authorization.server/oauth2/authorize"
+    token_endpoint = "{{.asOrigin}}/token"
+    token_endpoint_auth_method = "client_secret_post"
+    pkce {
+      code_challenge_method = "S256"
+      code_verifier_value = request.cookies.pkcecv
+    }
+    client_id = "foo"
+    client_secret = "etbinbp4in"
+    error_handler {
+      response {
+        status = 400
+      }
+    }
+  }
+}
diff --git a/server/testdata/oauth2/06_couper.hcl b/server/testdata/oauth2/06_couper.hcl
new file mode 100644
index 00000000..48c32de0
--- /dev/null
+++ b/server/testdata/oauth2/06_couper.hcl
@@ -0,0 +1,24 @@
+server "client" {
+  api {
+    endpoint "/cb" {
+      access_control = ["ac"]
+      response {
+        json_body = request.context.ac
+      }
+    }
+  }
+}
+definitions {
+  beta_oauth2 "ac" {
+    grant_type = "authorization_code"
+    redirect_uri = "http://localhost:8080/cb" # value is not checked
+    authorization_endpoint = "https://authorization.server/oauth2/authorize"
+    token_endpoint = "{{.asOrigin}}/token"
+    client_id = "foo"
+    client_secret = "etbinbp4in"
+    csrf {
+      token_param = "state"
+      token_value = request.cookies.st
+    }
+  }
+}
diff --git a/server/testdata/oauth2/07_couper.hcl b/server/testdata/oauth2/07_couper.hcl
new file mode 100644
index 00000000..2199f27f
--- /dev/null
+++ b/server/testdata/oauth2/07_couper.hcl
@@ -0,0 +1,27 @@
+server "client" {
+  api {
+    endpoint "/cb" {
+      access_control = ["ac"]
+      response {
+        json_body = request.context.ac
+      }
+    }
+  }
+}
+definitions {
+  beta_oauth2 "ac" {
+    grant_type = "authorization_code"
+    redirect_uri = "http://localhost:8080/cb" # value is not checked
+    issuer = "https://authorization.server"
+    authorization_endpoint = "https://authorization.server/oauth2/authorize"
+    scope = "openid profile email"
+    token_endpoint = "{{.asOrigin}}/token"
+    userinfo_endpoint = "{{.asOrigin}}/userinfo"
+    client_id = "foo"
+    client_secret = "etbinbp4in"
+    csrf {
+      token_param = "nonce"
+      token_value = request.cookies.nnc
+    }
+  }
+}
diff --git a/server/testdata/oauth2/0_retries_couper.hcl b/server/testdata/oauth2/0_retries_couper.hcl
index ee0df23a..53940831 100644
--- a/server/testdata/oauth2/0_retries_couper.hcl
+++ b/server/testdata/oauth2/0_retries_couper.hcl
@@ -1,17 +1,17 @@
 server "api" {
-  error_file = "./../integration/server_error.html"
+  error_file = "./testdata/integration/server_error.html"
 
   api {
-    error_file = "./../integration/api_error.json"
+    error_file = "./testdata/integration/api_error.json"
 
     endpoint "/" {
       proxy {
         backend {
-          origin = "${request.headers.x-origin}"
+          origin = "{{.rsOrigin}}"
           path   = "/resource"
 
           oauth2 {
-            token_endpoint = "${request.headers.x-token-endpoint}/oauth2"
+            token_endpoint = "{{.asOrigin}}/oauth2"
             client_id      = "user"
             client_secret  = "pass word"
             grant_type     = "client_credentials"
@@ -24,7 +24,7 @@ server "api" {
     endpoint "/2nd" {
       proxy {
         backend {
-          origin = "${request.headers.x-origin}"
+          origin = "{{.rsOrigin}}"
           path   = "/resource"
 
           oauth2 {
@@ -33,7 +33,7 @@ server "api" {
             grant_type     = "client_credentials"
             retries        = 0
             backend {
-              origin = "${request.headers.x-token-endpoint}"
+              origin = "{{.asOrigin}}"
               path = "/oauth2"
             }
           }
diff --git a/server/testdata/oauth2/1_retries_couper.hcl b/server/testdata/oauth2/1_retries_couper.hcl
index 1a229b10..313c7b8c 100644
--- a/server/testdata/oauth2/1_retries_couper.hcl
+++ b/server/testdata/oauth2/1_retries_couper.hcl
@@ -1,17 +1,17 @@
 server "api" {
-  error_file = "./../integration/server_error.html"
+  error_file = "./testdata/integration/server_error.html"
 
   api {
-    error_file = "./../integration/api_error.json"
+    error_file = "./testdata/integration/api_error.json"
 
     endpoint "/" {
       proxy {
         backend {
-          origin = "${request.headers.x-origin}"
+          origin = "{{.rsOrigin}}"
           path   = "/resource"
 
           oauth2 {
-            token_endpoint = "${request.headers.x-token-endpoint}/oauth2"
+            token_endpoint = "{{.asOrigin}}/oauth2"
             client_id      = "user"
             client_secret  = "pass"
             grant_type     = "client_credentials"
@@ -23,7 +23,7 @@ server "api" {
     endpoint "/2nd" {
       proxy {
         backend {
-          origin = "${request.headers.x-origin}"
+          origin = "{{.rsOrigin}}"
           path   = "/resource"
 
           oauth2 {
@@ -31,7 +31,7 @@ server "api" {
             client_secret  = "pass"
             grant_type     = "client_credentials"
             backend {
-              origin = "${request.headers.x-token-endpoint}"
+              origin = "{{.asOrigin}}"
               path = "/oauth2"
             }
           }
diff --git a/server/testdata/oauth2/2_retries_couper.hcl b/server/testdata/oauth2/2_retries_couper.hcl
index 30ddeae2..5739f50b 100644
--- a/server/testdata/oauth2/2_retries_couper.hcl
+++ b/server/testdata/oauth2/2_retries_couper.hcl
@@ -1,17 +1,17 @@
 server "api" {
-  error_file = "./../integration/server_error.html"
+  error_file = "./testdata/integration/server_error.html"
 
   api {
-    error_file = "./../integration/api_error.json"
+    error_file = "./testdata/integration/api_error.json"
 
     endpoint "/" {
       proxy {
         backend {
-          origin = "${request.headers.x-origin}"
+          origin = "{{.rsOrigin}}"
           path   = "/resource"
 
           oauth2 {
-            token_endpoint = "${request.headers.x-token-endpoint}/oauth2"
+            token_endpoint = "{{.asOrigin}}/oauth2"
             client_id      = "user"
             client_secret  = "pass"
             grant_type     = "client_credentials"
@@ -24,7 +24,7 @@ server "api" {
     endpoint "/2nd" {
       proxy {
         backend {
-          origin = "${request.headers.x-origin}"
+          origin = "{{.rsOrigin}}"
           path   = "/resource"
 
           oauth2 {
@@ -33,7 +33,7 @@ server "api" {
             grant_type     = "client_credentials"
             retries        = 2
             backend {
-              origin = "${request.headers.x-token-endpoint}"
+              origin = "{{.asOrigin}}"
               path = "/oauth2"
             }
           }