Repository: avenga/couper
Pull request #444 (2022-02-23 17:07:13)

Allowed methods
Specify allowed methods using the `allowed_methods` attribute in `api` or `endpoint` blocks.

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 427b4104..ec4f9759 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
   * [`backend_request`](./docs/REFERENCE.md#backend_request) and [`backend_response`](./docs/REFERENCE.md#backend_response) variables ([#430](https://github.com/avenga/couper/pull/430))
   * `beta_scope_map` attribute for the [JWT Block](./docs/REFERENCE.md#jwt-block) ([#434](https://github.com/avenga/couper/pull/434))
   * `saml` [error type](./docs/ERRORS.md#error-types) ([#424](https://github.com/avenga/couper/pull/424))
+  * `allowed_methods` attribute for the [API](./docs/REFERENCE.md#api-block) or [Endpoint Block](./docs/REFERENCE.md#endpoint-block) ([#444](https://github.com/avenga/couper/pull/444))
 
 * **Changed**
   * Automatically add the `private` directive to the response `Cache-Control` HTTP header field value for all resources protected by [JWT](./docs/REFERENCE.md#jwt-block) ([#418](https://github.com/avenga/couper/pull/418))
diff --git a/config/api.go b/config/api.go
index 3383de8f..fe0689a6 100644
--- a/config/api.go
+++ b/config/api.go
@@ -12,6 +12,7 @@ var _ Inline = &API{}
 type API struct {
 	ErrorHandlerSetter
 	AccessControl        []string  `hcl:"access_control,optional"`
+	AllowedMethods       []string  `hcl:"allowed_methods,optional"`
 	BasePath             string    `hcl:"base_path,optional"`
 	CORS                 *CORS     `hcl:"cors,block"`
 	DisableAccessControl []string  `hcl:"disable_access_control,optional"`
diff --git a/config/configload/endpoint.go b/config/configload/endpoint.go
index 91c121f4..a4dd2283 100644
--- a/config/configload/endpoint.go
+++ b/config/configload/endpoint.go
@@ -54,6 +54,12 @@ func refineEndpoints(definedBackends Backends, endpoints config.Endpoints, check
 
 		endpointContent := bodyToContent(endpoint.Remain)
 
+		if check && endpoint.AllowedMethods != nil && len(endpoint.AllowedMethods) > 0 {
+			if err = validMethods(endpoint.AllowedMethods, &endpointContent.Attributes["allowed_methods"].Range); err != nil {
+				return err
+			}
+		}
+
 		proxies := endpointContent.Blocks.OfType(proxy)
 		requests := endpointContent.Blocks.OfType(request)
 
diff --git a/config/configload/load.go b/config/configload/load.go
index 518a987f..1df353b1 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -304,6 +304,12 @@ func LoadConfig(body hcl.Body, src []byte, filename string) (*config.Couper, err
 				apiConfig.Name = apiBlock.Labels[0]
 			}
 
+			if apiConfig.AllowedMethods != nil && len(apiConfig.AllowedMethods) > 0 {
+				if err = validMethods(apiConfig.AllowedMethods, &bodyToContent(apiConfig.Remain).Attributes["allowed_methods"].Range); err != nil {
+					return nil, err
+				}
+			}
+
 			err := refineEndpoints(definedBackends, apiConfig.Endpoints, true)
 			if err != nil {
 				return nil, err
diff --git a/config/configload/validate.go b/config/configload/validate.go
index 42aaec2b..74b11428 100644
--- a/config/configload/validate.go
+++ b/config/configload/validate.go
@@ -2,10 +2,15 @@ package configload
 
 import (
 	"fmt"
+	"regexp"
 
 	"github.com/hashicorp/hcl/v2"
 )
 
+// https://datatracker.ietf.org/doc/html/rfc7231#section-4
+// https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
+var methodRegExp = regexp.MustCompile("^[!#$%&'*+\\-.\\^_`|~0-9a-zA-Z]+$")
+
 func validLabelName(name string, hr *hcl.Range) error {
 	if !regexProxyRequestLabel.MatchString(name) {
 		return hcl.Diagnostics{&hcl.Diagnostic{
@@ -53,3 +58,17 @@ func verifyBodyAttributes(content *hcl.BodyContent) error {
 	}
 	return nil
 }
+
+func validMethods(methods []string, hr *hcl.Range) error {
+	for _, method := range methods {
+		if !methodRegExp.MatchString(method) {
+			return hcl.Diagnostics{&hcl.Diagnostic{
+				Severity: hcl.DiagError,
+				Summary:  "method contains invalid character(s)",
+				Subject:  hr,
+			}}
+		}
+	}
+
+	return nil
+}
diff --git a/config/endpoint.go b/config/endpoint.go
index d0f5b0aa..6436751c 100644
--- a/config/endpoint.go
+++ b/config/endpoint.go
@@ -14,6 +14,7 @@ var _ Inline = &Endpoint{}
 type Endpoint struct {
 	ErrorHandlerSetter
 	AccessControl        []string  `hcl:"access_control,optional"`
+	AllowedMethods       []string  `hcl:"allowed_methods,optional"`
 	DisableAccessControl []string  `hcl:"disable_access_control,optional"`
 	ErrorFile            string    `hcl:"error_file,optional"`
 	Pattern              string    `hcl:"pattern,label"`
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 98617837..bf210b97 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -166,7 +166,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 				return nil, err
 			}
 
-			corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Spa))
+			corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Spa), nil)
 			if cerr != nil {
 				return nil, cerr
 			}
@@ -210,7 +210,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 				return nil, err
 			}
 
-			corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Files))
+			corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Files), nil)
 			if cerr != nil {
 				return nil, cerr
 			}
@@ -320,6 +320,20 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 			}
 
 			accessControl := newAC(srvConf, parentAPI)
+
+			allowedMethods := endpointConf.AllowedMethods
+			if allowedMethods == nil && parentAPI != nil {
+				// if allowed_methods in endpoint {} not defined, try allowed_methods in parent api {}
+				allowedMethods = parentAPI.AllowedMethods
+			}
+			notAllowedMethodsHandler := epOpts.ErrorTemplate.WithError(errors.MethodNotAllowed)
+			var allowedMethodsHandler *middleware.AllowedMethodsHandler
+			allowedMethodsHandler, err = middleware.NewAllowedMethodsHandler(allowedMethods, protectedHandler, notAllowedMethodsHandler)
+			if err != nil {
+				return nil, err
+			}
+			protectedHandler = allowedMethodsHandler
+
 			epHandler, err = configureProtectedHandler(accessControls, confCtx, accessControl,
 				config.NewAccessControl(endpointConf.AccessControl, endpointConf.DisableAccessControl),
 				&protectedOptions{
@@ -333,7 +347,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 				return nil, err
 			}
 
-			corsOptions, err := middleware.NewCORSOptions(whichCORS(srvConf, parentAPI))
+			corsOptions, err := middleware.NewCORSOptions(whichCORS(srvConf, parentAPI), allowedMethodsHandler.MethodAllowed)
 			if err != nil {
 				return nil, err
 			}
diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md
index 8307a993..a0bbec80 100644
--- a/docs/REFERENCE.md
+++ b/docs/REFERENCE.md
@@ -112,6 +112,7 @@ as json error with an error body payload. This can be customized via `error_file
 |`base_path`|string|-|Configures the path prefix for all requests.|&#9888; Must be unique if multiple `api` blocks are defined.| `base_path = "/v1"`|
 | `error_file` |string|-|Location of the error file template.|-|`error_file = "./my_error_body.json"`|
 | `access_control` |list|-|Sets predefined [Access Control](#access-control) for `api` block context.|&#9888; Inherited by nested blocks.| `access_control = ["foo"]`|
+| `allowed_methods` | tuple of string | `["*"]` == `["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]` | Sets allowed methods as _default_ for all contained endpoints. Requests with a method that is not allowed result in an error response with a `405 Method Not Allowed` status. | The default value `*` can be combined with additional methods. Methods are matched case-insensitively. `Access-Control-Allow-Methods` is only sent in response to a [CORS](#cors-block) preflight request, if the method requested by `Access-Control-Request-Method` is an allowed method. | `allowed_methods = ["GET", "POST"]` or `allowed_methods = ["*", "BREW"]` |
 | `beta_scope` |string or object|-|Scope value required to use this API (see [error type](ERRORS.md#error-types) `beta_insufficient_scope`).|If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required".| `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }`|
 | `custom_log_fields` | map | - | Defines log fields for [Custom Logging](LOGS.md#custom-logging). | &#9888; Inherited by nested blocks. | - |
 
@@ -134,6 +135,7 @@ produce an explicit or implicit client response.
 | `request_body_limit`    | string           | `64MiB` | Configures the maximum buffer size while accessing `request.form_body` or `request.json_body` content.            | &#9888; Valid units are: `KiB, MiB, GiB`                                                                                                                                                                                                                                                                                                                                                                                                        | `request_body_limit = "200KiB"`                                      |
 | `path`                  | string           | -       | Changeable part of the upstream URL. Changes the path suffix of the outgoing request.                             | -                                                                                                                                                                                                                                                                                                                                                                                                                                               | -                                                                    |
 | `access_control`        | list             | -       | Sets predefined [Access Control](#access-control) for `endpoint` block context.                                   | -                                                                                                                                                                                                                                                                                                                                                                                                                                               | `access_control = ["foo"]`                                           |
+| `allowed_methods`       | tuple of string  | `["*"]` == `["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]` | Sets allowed methods _overriding_ a default set in the containing `api` block. Requests with a method that is not allowed result in an error response with a `405 Method Not Allowed` status. | The default value `*` can be combined with additional methods. Methods are matched case-insensitively. `Access-Control-Allow-Methods` is only sent in response to a [CORS](#cors-block) preflight request, if the method requested by `Access-Control-Request-Method` is an allowed method. | `allowed_methods = ["GET", "POST"]` or `allowed_methods = ["*", "BREW"]` |
 | `beta_scope`            | string or object | -       | Scope value required to use this endpoint (see [error type](ERRORS.md#error-types) `beta_insufficient_scope`). | If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required". | `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }` |
 | `custom_log_fields`     | map              | -       | Defines log fields for [Custom Logging](LOGS.md#custom-logging).                                                  | &#9888; Inherited by nested blocks.                                                                                                                                                                                                                                                                                                                                                                                                             | -                                                                    |
 | [Modifiers](#modifiers) | -                | -       | -                                                                                                                 | -                                                                                                                                                                                                                                                                                                                                                                                                                                               | -                                                                    |
@@ -281,6 +283,8 @@ The `cors` block configures the CORS (Cross-Origin Resource Sharing) behavior in
 | `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|`max_age = "1h"`|
 
+**Note:** `Access-Control-Allow-Methods` is only sent in response to a CORS preflight request, if the method requested by `Access-Control-Request-Method` is an allowed method (see the `allowed_method` attribute for [`api`](#api-block) or [`endpoint`](#endpoint-block) blocks).
+
 ### OAuth2 CC Block
 
 The `oauth2` block in the [Backend Block](#backend-block) context configures the OAuth2 Client Credentials flow to request a bearer token for the backend request.
diff --git a/errors/couper.go b/errors/couper.go
index 0735197f..896ce451 100644
--- a/errors/couper.go
+++ b/errors/couper.go
@@ -18,6 +18,7 @@ var (
 	Endpoint          = &Error{synopsis: "endpoint error", kinds: []string{"endpoint"}, httpStatus: http.StatusBadGateway}
 	Evaluation        = &Error{synopsis: "expression evaluation error", kinds: []string{"evaluation"}, httpStatus: http.StatusInternalServerError}
 	Configuration     = &Error{synopsis: "configuration error", kinds: []string{"configuration"}, httpStatus: http.StatusInternalServerError}
+	MethodNotAllowed  = &Error{synopsis: "method not allowed error", httpStatus: http.StatusMethodNotAllowed}
 	Proxy             = &Error{synopsis: "proxy error", httpStatus: http.StatusBadGateway}
 	Request           = &Error{synopsis: "request error", httpStatus: http.StatusBadGateway}
 	RouteNotFound     = &Error{synopsis: "route not found error", httpStatus: http.StatusNotFound}
diff --git a/handler/middleware/allowed_methods.go b/handler/middleware/allowed_methods.go
new file mode 100644
index 00000000..6f6dfc61
--- /dev/null
+++ b/handler/middleware/allowed_methods.go
@@ -0,0 +1,68 @@
+package middleware
+
+import (
+	"net/http"
+	"strings"
+)
+
+var defaultAllowedMethods = []string{
+	http.MethodGet,
+	http.MethodHead,
+	http.MethodPost,
+	http.MethodPut,
+	http.MethodPatch,
+	http.MethodDelete,
+	http.MethodOptions,
+}
+
+var _ http.Handler = &AllowedMethodsHandler{}
+
+type AllowedMethodsHandler struct {
+	allowedMethods    map[string]struct{}
+	allowedHandler    http.Handler
+	notAllowedHandler http.Handler
+}
+
+type methodAllowedFunc func(string) bool
+
+func NewAllowedMethodsHandler(allowedMethods []string, allowedHandler, notAllowedHandler http.Handler) (*AllowedMethodsHandler, error) {
+	amh := &AllowedMethodsHandler{
+		allowedMethods:    make(map[string]struct{}),
+		allowedHandler:    allowedHandler,
+		notAllowedHandler: notAllowedHandler,
+	}
+	if allowedMethods == nil {
+		allowedMethods = defaultAllowedMethods
+	}
+	for _, method := range allowedMethods {
+		if method == "*" {
+			for _, m := range defaultAllowedMethods {
+				amh.allowedMethods[m] = struct{}{}
+			}
+		} else {
+			method = strings.ToUpper(method)
+			amh.allowedMethods[method] = struct{}{}
+		}
+	}
+
+	return amh, nil
+}
+
+func (a *AllowedMethodsHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+	if _, ok := a.allowedMethods[req.Method]; !ok {
+		a.notAllowedHandler.ServeHTTP(rw, req)
+		return
+	}
+
+	a.allowedHandler.ServeHTTP(rw, req)
+}
+
+func (a *AllowedMethodsHandler) MethodAllowed(method string) bool {
+	method = strings.TrimSpace(strings.ToUpper(method))
+	_, ok := a.allowedMethods[method]
+	return ok
+}
+
+func (a *AllowedMethodsHandler) Child() http.Handler {
+	return a.allowedHandler
+}
diff --git a/handler/middleware/cors.go b/handler/middleware/cors.go
index e3894d27..8947f978 100644
--- a/handler/middleware/cors.go
+++ b/handler/middleware/cors.go
@@ -23,9 +23,10 @@ type CORSOptions struct {
 	AllowedOrigins   []string
 	AllowCredentials bool
 	MaxAge           string
+	methodAllowed    methodAllowedFunc
 }
 
-func NewCORSOptions(cors *config.CORS) (*CORSOptions, error) {
+func NewCORSOptions(cors *config.CORS, methodAllowed methodAllowedFunc) (*CORSOptions, error) {
 	if cors == nil {
 		return nil, nil
 	}
@@ -49,6 +50,7 @@ func NewCORSOptions(cors *config.CORS) (*CORSOptions, error) {
 		AllowedOrigins:   allowedOrigins,
 		AllowCredentials: cors.AllowCredentials,
 		MaxAge:           corsMaxAge,
+		methodAllowed:    methodAllowed,
 	}, nil
 }
 
@@ -128,7 +130,9 @@ func (c *CORS) setCorsRespHeaders(headers http.Header, req *http.Request) {
 		// Reflect request header value
 		acrm := req.Header.Get("Access-Control-Request-Method")
 		if acrm != "" {
-			headers.Set("Access-Control-Allow-Methods", acrm)
+			if c.options.methodAllowed == nil || c.options.methodAllowed(acrm) {
+				headers.Set("Access-Control-Allow-Methods", acrm)
+			}
 			headers.Add("Vary", "Access-Control-Request-Method")
 		}
 		// Reflect request header value
diff --git a/server/http.go b/server/http.go
index 65bbf13c..7f59cc5f 100644
--- a/server/http.go
+++ b/server/http.go
@@ -72,7 +72,7 @@ func New(cmdCtx, evalCtx context.Context, log logrus.FieldLogger, settings *conf
 	muxersList := make(muxers)
 	for host, muxOpts := range hosts {
 		mux := NewMux(muxOpts)
-		mux.MustAddRoute(http.MethodGet, settings.HealthPath, handler.NewHealthCheck(settings.HealthPath, shutdownCh))
+		mux.mustAddRoute(mux.endpointRoot, []string{http.MethodGet}, settings.HealthPath, handler.NewHealthCheck(settings.HealthPath, shutdownCh), true)
 
 		muxersList[host] = mux
 	}
diff --git a/server/mux.go b/server/mux.go
index 41bfa942..a95cac2e 100644
--- a/server/mux.go
+++ b/server/mux.go
@@ -29,16 +29,6 @@ type Mux struct {
 	spaRoot      *pathpattern.Node
 }
 
-var allowedMethods = []string{
-	http.MethodGet,
-	http.MethodHead,
-	http.MethodPost,
-	http.MethodPut,
-	http.MethodPatch,
-	http.MethodDelete,
-	http.MethodOptions,
-}
-
 var fileMethods = []string{
 	http.MethodGet,
 	http.MethodHead,
@@ -67,7 +57,7 @@ func NewMux(options *runtime.MuxOptions) *Mux {
 
 	for path, h := range opts.EndpointRoutes {
 		// TODO: handle method option per endpoint configuration
-		mux.mustAddRoute(mux.endpointRoot, allowedMethods, path, h, true)
+		mux.mustAddRoute(mux.endpointRoot, nil, path, h, true)
 	}
 
 	for path, h := range opts.FileRoutes {
@@ -81,26 +71,6 @@ func NewMux(options *runtime.MuxOptions) *Mux {
 	return mux
 }
 
-func (m *Mux) MustAddRoute(method, path string, handler http.Handler) *Mux {
-	methods := allowedMethods[:]
-	if method != "*" {
-		um := strings.ToUpper(method)
-		var allowed bool
-		for _, am := range allowedMethods {
-			if um == am {
-				allowed = true
-				break
-			}
-		}
-		if !allowed {
-			panic(fmt.Errorf("method not allowed: %q, path: %q", um, path))
-		}
-
-		methods = []string{um}
-	}
-	return m.mustAddRoute(m.endpointRoot, methods, path, handler, false)
-}
-
 func (m *Mux) mustAddRoute(root *pathpattern.Node, methods []string, path string, handler http.Handler, forEndpoint bool) *Mux {
 	if forEndpoint && strings.HasSuffix(path, wildcardSearch) {
 		route := mustCreateNode(root, handler, "", path)
@@ -108,6 +78,14 @@ func (m *Mux) mustAddRoute(root *pathpattern.Node, methods []string, path string
 		return m
 	}
 
+	if methods == nil {
+		// EndpointRoutes allowed methods are handled by handler
+		route := mustCreateNode(root, handler, "", path)
+		m.handler[route] = handler
+
+		return m
+	}
+
 	for _, method := range methods {
 		route := mustCreateNode(root, handler, method, path)
 		m.handler[route] = handler


Test patch:
diff --git a/handler/middleware/cors_test.go b/handler/middleware/cors_test.go
index b560fb89..0e50c9f6 100644
--- a/handler/middleware/cors_test.go
+++ b/handler/middleware/cors_test.go
@@ -364,6 +364,11 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		}
 	})
 
+	var methodAllowed methodAllowedFunc
+	methodAllowed = func(method string) bool {
+		return method == http.MethodPost
+	}
+
 	tests := []struct {
 		name                    string
 		corsOptions             *CORSOptions
@@ -373,7 +378,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 	}{
 		{
 			"specific origin, with ACRM",
-			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}},
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                        "https://www.example.com",
 				"Access-Control-Request-Method": "POST",
@@ -387,9 +392,25 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 			},
 			[]string{"Origin", "Access-Control-Request-Method"},
 		},
+		{
+			"specific origin, with ACRM, method not allowed",
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed},
+			map[string]string{
+				"Origin":                        "https://www.example.com",
+				"Access-Control-Request-Method": "PUT",
+			},
+			map[string]string{
+				"Access-Control-Allow-Origin":      "https://www.example.com",
+				"Access-Control-Allow-Methods":     "",
+				"Access-Control-Allow-Headers":     "",
+				"Access-Control-Allow-Credentials": "",
+				"Access-Control-Max-Age":           "",
+			},
+			[]string{"Origin", "Access-Control-Request-Method"},
+		},
 		{
 			"specific origin, with ACRH",
-			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}},
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                         "https://www.example.com",
 				"Access-Control-Request-Headers": "X-Foo, X-Bar",
@@ -405,7 +426,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		},
 		{
 			"specific origin, with ACRM, ACRH",
-			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}},
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                         "https://www.example.com",
 				"Access-Control-Request-Method":  "POST",
@@ -422,7 +443,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		},
 		{
 			"specific origin, with ACRM, credentials",
-			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true},
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                        "https://www.example.com",
 				"Access-Control-Request-Method": "POST",
@@ -438,7 +459,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		},
 		{
 			"specific origin, with ACRM, max-age",
-			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, MaxAge: "3600"},
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, MaxAge: "3600", methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                        "https://www.example.com",
 				"Access-Control-Request-Method": "POST",
@@ -454,7 +475,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		},
 		{
 			"any origin, with ACRM",
-			&CORSOptions{AllowedOrigins: []string{"*"}},
+			&CORSOptions{AllowedOrigins: []string{"*"}, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                        "https://www.example.com",
 				"Access-Control-Request-Method": "POST",
@@ -470,7 +491,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		},
 		{
 			"any origin, with ACRM, credentials",
-			&CORSOptions{AllowedOrigins: []string{"*"}, AllowCredentials: true},
+			&CORSOptions{AllowedOrigins: []string{"*"}, AllowCredentials: true, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                        "https://www.example.com",
 				"Access-Control-Request-Method": "POST",
@@ -486,7 +507,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		},
 		{
 			"origin mismatch",
-			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}},
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                        "https://www.example.org",
 				"Access-Control-Request-Method": "POST",
@@ -502,7 +523,7 @@ func TestProxy_ServeHTTP_CORS_PFC(t *testing.T) {
 		},
 		{
 			"origin mismatch, credentials",
-			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true},
+			&CORSOptions{AllowedOrigins: []string{"https://www.example.com"}, AllowCredentials: true, methodAllowed: methodAllowed},
 			map[string]string{
 				"Origin":                        "https://www.example.org",
 				"Access-Control-Request-Method": "POST",
diff --git a/main_test.go b/main_test.go
index a55776f3..1d91a1e4 100644
--- a/main_test.go
+++ b/main_test.go
@@ -42,6 +42,9 @@ func Test_realmain(t *testing.T) {
 		{"non-existent log level via env /w file", []string{"couper", "run", "-f", base + "/log_altered.hcl"}, []string{"COUPER_LOG_LEVEL=test"}, `level=error msg="configuration error: missing 'server' block" build=dev`, 1},
 		{"-f w/o file", []string{"couper", "run", "-f"}, nil, `level=error msg="flag needs an argument: -f" build=dev`, 1},
 		{"undefined AC", []string{"couper", "run", "-f", base + "/04_couper.hcl"}, nil, `level=error msg="accessControl is not defined: undefined" build=dev`, 1},
+		{"empty string in allowed_methods in endpoint", []string{"couper", "run", "-f", base + "/13_couper.hcl"}, nil, `level=error msg="13_couper.hcl:3,5-27: method contains invalid character(s); " build=dev`, 1},
+		{"invalid method in allowed_methods in endpoint", []string{"couper", "run", "-f", base + "/14_couper.hcl"}, nil, `level=error msg="14_couper.hcl:3,5-35: method contains invalid character(s); " build=dev`, 1},
+		{"invalid method in allowed_methods in api", []string{"couper", "run", "-f", base + "/15_couper.hcl"}, nil, `level=error msg="15_couper.hcl:3,5-35: method contains invalid character(s); " build=dev`, 1},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index f89c8793..69cf6cbd 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -3131,7 +3131,7 @@ func TestAPICatchAll(t *testing.T) {
 		{"exists, CORS pre-flight", "/v5/exists", http.MethodOptions, http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}}, http.StatusNoContent, ""},
 		{"not-exist, authorized", "/v5/not-exist", http.MethodGet, http.Header{"Authorization": []string{"Basic OmFzZGY="}}, http.StatusNotFound, "route not found error"},
 		{"not-exist, unauthorized", "/v5/not-exist", http.MethodGet, http.Header{}, http.StatusUnauthorized, "access control error: ba1: credentials required"},
-		{"not-exist, non-standard method, authorized", "/v5/not-exist", "BREW", http.Header{"Authorization": []string{"Basic OmFzZGY="}}, http.StatusNotFound, "route not found error"},
+		{"not-exist, non-standard method, authorized", "/v5/not-exist", "BREW", http.Header{"Authorization": []string{"Basic OmFzZGY="}}, http.StatusMethodNotAllowed, "method not allowed error"},
 		{"not-exist, non-standard method, unauthorized", "/v5/not-exist", "BREW", http.Header{}, http.StatusUnauthorized, "access control error: ba1: credentials required"},
 		{"not-exist, CORS pre-flight", "/v5/not-exist", http.MethodOptions, http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}}, http.StatusNoContent, ""},
 	} {
@@ -4515,6 +4515,179 @@ func TestOIDCDefaultNonceFunctions(t *testing.T) {
 	}
 }
 
+func TestAllowedMethods(t *testing.T) {
+	client := newClient()
+
+	confPath := "testdata/integration/config/11_couper.hcl"
+	shutdown, logHook := newCouper(confPath, test.New(t))
+	defer shutdown()
+
+	token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZSI6ImZvbyJ9.7zkwmXTmzFTKHC0Qnpw7uQCcacogWUvi_JU56uWJlkw"
+
+	type testCase struct {
+		name           string
+		method         string
+		path           string
+		requestHeaders http.Header
+		status         int
+		couperError    string
+	}
+
+	for _, tc := range []testCase{
+		{"path not found, authorized", http.MethodGet, "/api1/not-found", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusNotFound, "route not found error"},
+
+		{"unrestricted, authorized, GET", http.MethodGet, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""},
+		{"unrestricted, authorized, HEAD", http.MethodHead, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""},
+		{"unrestricted, authorized, POST", http.MethodPost, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""},
+		{"unrestricted, authorized, PUT", http.MethodPut, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""},
+		{"unrestricted, authorized, PATCH", http.MethodPatch, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""},
+		{"unrestricted, authorized, DELETE", http.MethodDelete, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"},
+		{"unrestricted, authorized, OPTIONS", http.MethodOptions, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"},
+		{"unrestricted, authorized, CONNECT", http.MethodConnect, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"unrestricted, authorized, TRACE", http.MethodTrace, "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"unrestricted, authorized, BREW", "BREW", "/api1/unrestricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"unrestricted, unauthorized, GET", http.MethodGet, "/api1/unrestricted", http.Header{}, http.StatusUnauthorized, "access control error"},
+		{"unrestricted, unauthorized, BREW", "BREW", "/api1/unrestricted", http.Header{}, http.StatusUnauthorized, "access control error"},
+		{"unrestricted, CORS preflight", http.MethodOptions, "/api1/unrestricted", http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}, "Access-Control-Request-Headers": []string{"Authorization"}}, http.StatusNoContent, ""},
+
+		{"restricted, authorized, GET", http.MethodGet, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""},
+		{"restricted, authorized, HEAD", http.MethodHead, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted, authorized, POST", http.MethodPost, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusOK, ""},
+		{"restricted, authorized, PUT", http.MethodPut, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted, authorized, PATCH", http.MethodPatch, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted, authorized, DELETE", http.MethodDelete, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"},
+		{"restricted, authorized, OPTIONS", http.MethodOptions, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted, authorized, CONNECT", http.MethodConnect, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted, authorized, TRACE", http.MethodTrace, "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted, authorized, BREW", "BREW", "/api1/restricted", http.Header{"Authorization": []string{"Bearer " + token}}, http.StatusForbidden, "access control error"}, // BREW not supported by scope AC
+		{"restricted, CORS preflight", http.MethodOptions, "/api1/restricted", http.Header{"Origin": []string{"https://www.example.com"}, "Access-Control-Request-Method": []string{"POST"}, "Access-Control-Request-Headers": []string{"Authorization"}}, http.StatusNoContent, ""},
+
+		{"wildcard, GET", http.MethodGet, "/api1/wildcard", http.Header{}, http.StatusOK, ""},
+		{"wildcard, HEAD", http.MethodHead, "/api1/wildcard", http.Header{}, http.StatusOK, ""},
+		{"wildcard, POST", http.MethodPost, "/api1/wildcard", http.Header{}, http.StatusOK, ""},
+		{"wildcard, PUT", http.MethodPut, "/api1/wildcard", http.Header{}, http.StatusOK, ""},
+		{"wildcard, PATCH", http.MethodPatch, "/api1/wildcard", http.Header{}, http.StatusOK, ""},
+		{"wildcard, DELETE", http.MethodDelete, "/api1/wildcard", http.Header{}, http.StatusOK, ""},
+		{"wildcard, OPTIONS", http.MethodOptions, "/api1/wildcard", http.Header{}, http.StatusOK, ""},
+		{"wildcard, CONNECT", http.MethodConnect, "/api1/wildcard", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"wildcard, TRACE", http.MethodTrace, "/api1/wildcard", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"wildcard, BREW", "BREW", "/api1/wildcard", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+
+		{"wildcard and more, GET", http.MethodGet, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+		{"wildcard and more, HEAD", http.MethodHead, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+		{"wildcard and more, POST", http.MethodPost, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+		{"wildcard and more, PUT", http.MethodPut, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+		{"wildcard and more, PATCH", http.MethodPatch, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+		{"wildcard and more, DELETE", http.MethodDelete, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+		{"wildcard and more, OPTIONS", http.MethodOptions, "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+		{"wildcard and more, CONNECT", http.MethodConnect, "/api1/wildcardAndMore", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"wildcard and more, TRACE", http.MethodTrace, "/api1/wildcardAndMore", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"wildcard and more, BREW", "BREW", "/api1/wildcardAndMore", http.Header{}, http.StatusOK, ""},
+
+		{"blocked, GET", http.MethodGet, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, HEAD", http.MethodHead, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, POST", http.MethodPost, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, PUT", http.MethodPut, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, PATCH", http.MethodPatch, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, DELETE", http.MethodDelete, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, OPTIONS", http.MethodOptions, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, CONNECT", http.MethodConnect, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, TRACE", http.MethodTrace, "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"blocked, BREW", "BREW", "/api1/blocked", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+
+		{"restricted methods override, GET", http.MethodGet, "/api2/restricted", http.Header{}, http.StatusOK, ""},
+		{"restricted methods override, HEAD", http.MethodHead, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted methods override, POST", http.MethodPost, "/api2/restricted", http.Header{}, http.StatusOK, ""},
+		{"restricted methods override, PUT", http.MethodPut, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted methods override, PATCH", http.MethodPatch, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted methods override, DELETE", http.MethodDelete, "/api2/restricted", http.Header{}, http.StatusOK, ""},
+		{"restricted methods override, OPTIONS", http.MethodOptions, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted methods override, CONNECT", http.MethodConnect, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted methods override, TRACE", http.MethodTrace, "/api2/restricted", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted methods override, BREW", "BREW", "/api2/restricted", http.Header{}, http.StatusOK, ""},
+
+		{"restricted by api only, GET", http.MethodGet, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, HEAD", http.MethodHead, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, POST", http.MethodPost, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, PUT", http.MethodPut, "/api2/restrictedByApiOnly", http.Header{}, http.StatusOK, ""},
+		{"restricted by api only, PATCH", http.MethodPatch, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, DELETE", http.MethodDelete, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, OPTIONS", http.MethodOptions, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, CONNECT", http.MethodConnect, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, TRACE", http.MethodTrace, "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+		{"restricted by api only, BREW", "BREW", "/api2/restrictedByApiOnly", http.Header{}, http.StatusMethodNotAllowed, "method not allowed error"},
+	} {
+		t.Run(tc.name, func(subT *testing.T) {
+			helper := test.New(subT)
+			logHook.Reset()
+			req, err := http.NewRequest(tc.method, "http://example.com:8080"+tc.path, nil)
+			helper.Must(err)
+			req.Header = tc.requestHeaders
+
+			res, err := client.Do(req)
+			helper.Must(err)
+
+			if tc.status != res.StatusCode {
+				subT.Errorf("Unexpected status code given; want: %d; got: %d", tc.status, res.StatusCode)
+			}
+
+			couperError := res.Header.Get("Couper-Error")
+			if tc.couperError != couperError {
+				subT.Errorf("Unexpected couper-error given; want: %q; got: %q", tc.couperError, couperError)
+			}
+		})
+	}
+}
+
+func TestAllowedMethodsCORS_Preflight(t *testing.T) {
+	client := newClient()
+
+	confPath := "testdata/integration/config/11_couper.hcl"
+	shutdown, logHook := newCouper(confPath, test.New(t))
+	defer shutdown()
+
+	type testCase struct {
+		name          string
+		path          string
+		requestMethod string
+		status        int
+		allowMethods  []string
+		couperError   string
+	}
+
+	for _, tc := range []testCase{
+		{"unrestricted, CORS preflight, POST allowed", "/api1/unrestricted", http.MethodPost, http.StatusNoContent, []string{"POST"}, ""},
+		{"restricted, CORS preflight, POST allowed", "/api1/restricted", http.MethodPost, http.StatusNoContent, []string{"POST"}, ""}, // CORS preflight ok even if OPTIONS is otherwise not allowed
+		{"restricted, CORS preflight, PUT not allowed", "/api1/restricted", http.MethodPut, http.StatusNoContent, nil, ""},
+	} {
+		t.Run(tc.name, func(subT *testing.T) {
+			helper := test.New(subT)
+			logHook.Reset()
+			req, err := http.NewRequest(http.MethodOptions, "http://example.com:8080"+tc.path, nil)
+			helper.Must(err)
+			req.Header.Set("Origin", "https://www.example.com")
+			req.Header.Set("Access-Control-Request-Method", tc.requestMethod)
+
+			res, err := client.Do(req)
+			helper.Must(err)
+
+			if tc.status != res.StatusCode {
+				subT.Errorf("Unexpected status code given; want: %d; got: %d", tc.status, res.StatusCode)
+			}
+
+			allowMethods := res.Header.Values("Access-Control-Allow-Methods")
+			if !cmp.Equal(tc.allowMethods, allowMethods) {
+				subT.Errorf(cmp.Diff(tc.allowMethods, allowMethods))
+			}
+
+			couperError := res.Header.Get("Couper-Error")
+			if tc.couperError != couperError {
+				subT.Errorf("Unexpected couper-error given; want: %q; got: %q", tc.couperError, couperError)
+			}
+		})
+	}
+}
+
 func TestEndpoint_ResponseNilEvaluation(t *testing.T) {
 	client := newClient()
 
diff --git a/server/testdata/integration/config/11_couper.hcl b/server/testdata/integration/config/11_couper.hcl
new file mode 100644
index 00000000..d6020510
--- /dev/null
+++ b/server/testdata/integration/config/11_couper.hcl
@@ -0,0 +1,94 @@
+server {
+  api {
+    base_path = "/api1"
+
+    cors {
+      allowed_origins = ["*"]
+    }
+
+    endpoint "/unrestricted" {
+      access_control = ["token"]
+      beta_scope = {
+        get = "foo"
+        head = "foo"
+        post = "foo"
+        put = "foo"
+        patch = "foo"
+        # no delete, no options, no trace
+        brew = "foo"
+      }
+      response {
+        body = "a"
+      }
+    }
+
+    endpoint "/restricted" {
+      access_control = ["token"]
+      allowed_methods = ["GET", "Post", "delete", "BREW"]
+      beta_scope = {
+        get = "foo"
+        head = "foo"
+        post = "foo"
+        put = "foo"
+        patch = "foo"
+        # no delete, no options, no trace
+        brew = "foo"
+      }
+
+      response {
+        body = "a"
+      }
+    }
+
+    endpoint "/wildcard" {
+      allowed_methods = ["*"]
+
+      response {
+        body = "a"
+      }
+    }
+
+    endpoint "/wildcardAndMore" {
+      allowed_methods = ["get", "*", "PuT", "brew"]
+
+      response {
+        body = "a"
+      }
+    }
+
+    endpoint "/blocked" {
+      allowed_methods = []
+
+      response {
+        body = "a"
+      }
+    }
+  }
+
+  api {
+    base_path = "/api2"
+    allowed_methods = ["PUT"]
+
+    endpoint "/restricted" {
+      allowed_methods = ["GET", "Post", "delete", "BREW"]
+
+      response {
+        body = "a"
+      }
+    }
+
+    endpoint "/restrictedByApiOnly" {
+      response {
+        body = "a"
+      }
+    }
+  }
+}
+
+definitions {
+  jwt "token" {
+    signature_algorithm = "HS256"
+    key = "asdf"
+    beta_scope_claim = "scope"
+  }
+}
diff --git a/server/testdata/settings/13_couper.hcl b/server/testdata/settings/13_couper.hcl
new file mode 100644
index 00000000..774e67bf
--- /dev/null
+++ b/server/testdata/settings/13_couper.hcl
@@ -0,0 +1,8 @@
+server {
+  endpoint "/" {
+    allowed_methods = [""]
+    response {
+      body = "a"
+    }
+  }
+}
diff --git a/server/testdata/settings/14_couper.hcl b/server/testdata/settings/14_couper.hcl
new file mode 100644
index 00000000..fa3315c8
--- /dev/null
+++ b/server/testdata/settings/14_couper.hcl
@@ -0,0 +1,8 @@
+server {
+  endpoint "/" {
+    allowed_methods = ["in valid"]
+    response {
+      body = "a"
+    }
+  }
+}
diff --git a/server/testdata/settings/15_couper.hcl b/server/testdata/settings/15_couper.hcl
new file mode 100644
index 00000000..b05fe79f
--- /dev/null
+++ b/server/testdata/settings/15_couper.hcl
@@ -0,0 +1,5 @@
+server {
+  api {
+    allowed_methods = ["in valid"]
+  }
+}