Repository: oauth2-proxy/oauth2-proxy
Pull request #1771 (2022-08-26 06:54:12)

Updated release notes regarding azure provider issue


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index cb1f8f4e..973d1abf 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -20,15 +20,15 @@ jobs:
     - name: Check out code
       uses: actions/checkout@v2
 
-    - name: Set up Go 1.17
+    - name: Set up Go 1.19
       uses: actions/setup-go@v2
       with:
-        go-version: 1.17.x
+        go-version: 1.19.x
       id: go
 
     - name: Get dependencies
       run: |
-        curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.36.0
+        curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.50.0
         curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
         chmod +x ./cc-test-reporter
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f016f86f..98111cfb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,18 +3,47 @@
 ## Release Highlights
 
 ## Important Notes
+- [#1708](https://github.com/oauth2-proxy/oauth2-proxy/pull/1708) Enable different CSRF cookies per request (@miguelborges99)
+  - Since the CSRF cookie name is now longer it could potentially break long cookie names (around 1000 characters).
+  - Having a unique CSRF cookie per request can lead to quite a number of cookies, in case an application performs a high number of parallel authentication requests. Each call will redirect to /oauth2/start, if the user is not authenticated, and a new cookie will be set. The successfully authenticated requests will have its CSRF cookies immediatly expired, however the failed ones will mantain its CSRF cookies until they expire (by default in 15 minutes). 
+  - The user may redefine the CSRF cookie expiration time using flag "--cookie-csrf-expire" (e.g. --cookie-csrf-expire=5m). By default, it is 15 minutes, but you can fine tune to your environment.
+
+- [#1574](https://github.com/oauth2-proxy/oauth2-proxy/pull/1574) Add Azure groups support and Azure OAuth v2.0 (@adriananeci)
+  - group membership check is now validated while using the the azure provider.
+  - Azure OAuth v2.0 (https://login.microsoftonline.com/{tenant_id}/v2.0) is now available along with Azure OAuth v1.0. See https://github.com/oauth2-proxy/oauth2-proxy/blob/master/docs/docs/configuration/auth.md#azure-auth-provider for more details
+  - When using v2.0 Azure Auth endpoint (`https://login.microsoftonline.com/{tenant-id}/v2.0`) as `--oidc_issuer_url`, in conjunction with `--resource` flag, be sure to append `/.default` at the end of the resource name. See https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#the-default-scope for more details.
 
 ## Breaking Changes
 
+N/A
+
 ## Changes since v7.3.0
+- [#1828](https://github.com/oauth2-proxy/oauth2-proxy/pull/1828) call providerData.setProviderDefaults for oidc provider to achieve consistent behaviour (@centzilius)
+  - UserClaim will be set to sub instead of beeing empty from now on.
 - [#1691](https://github.com/oauth2-proxy/oauth2-proxy/pull/1691) Fix Redis IdleTimeout when Redis timeout option is set to non-zero (@dimss)
-
 - [#1669](https://github.com/oauth2-proxy/oauth2-proxy/pull/1699) Fix method deprecated error in lint (@t-katsumura)
-
+- [#1701](https://github.com/oauth2-proxy/oauth2-proxy/pull/1701) Watch the htpasswd file for changes and update the htpasswdMap (@aiciobanu)
 - [#1709](https://github.com/oauth2-proxy/oauth2-proxy/pull/1709) Show an alert message when basic auth credentials are invalid (@aiciobanu)
-
+- [#1723](https://github.com/oauth2-proxy/oauth2-proxy/pull/1723) Added ability to specify allowed TLS cipher suites. (@crbednarz)
 - [#1720](https://github.com/oauth2-proxy/oauth2-proxy/pull/1720) Extract roles from authToken, to allow using allowed roles with Keycloak.
-
+- [#1774](https://github.com/oauth2-proxy/oauth2-proxy/pull/1774) Fix vulnerabilities CVE-2022-27191, CVE-2021-44716 and CVE-2022-29526
+- [#1667](https://github.com/oauth2-proxy/oauth2-proxy/issues/1667) Rename configuration file flag for PKCE
+to remain consistent with CLI flags. You should specify `code_challenge_method` in your configuration instead of
+`force_code_challenge_method`.
+- [#1708](https://github.com/oauth2-proxy/oauth2-proxy/pull/1708) Enable different CSRF cookies per request (@miguelborges99) 
+  - Add flag "--cookie-csrf-per-request" which activates an algorithm to name CSRF cookies differently per request. 
+    This feature allows parallel callbacks and by default it is disabled. 
+  - Add flag "--cookie-csrf-expire" to define a different expiration time for the CSRF cookie. By default, it is 15 minutes.
+- [#1762](https://github.com/oauth2-proxy/oauth2-proxy/pull/1762) Support negating for skip auth routes
+- [#1788](https://github.com/oauth2-proxy/oauth2-proxy/pull/1788) Update base docker image to alpine 3.16
+- [#1760](https://github.com/oauth2-proxy/oauth2-proxy/pull/1760) Option to configure API routes
+- [#1825](https://github.com/oauth2-proxy/oauth2-proxy/pull/1825) Fix vulnerabilities CVE-2022-32149 and CVE-2022-27664. (@crbednarz)
+- [#1750](https://github.com/oauth2-proxy/oauth2-proxy/pull/1750) Fix Nextcloud provider
+- [#1574](https://github.com/oauth2-proxy/oauth2-proxy/pull/1574) Add Azure groups support and Azure OAuth v2.0 (@adriananeci)
+- [#1851](https://github.com/oauth2-proxy/oauth2-proxy/pull/1851) Bump golang to 1.19 and min allowed version to 1.18
+- [#1815](https://github.com/oauth2-proxy/oauth2-proxy/pull/1815) Keycloak: save user and preferredUsername in session to populate headers for the backend (@babs)
+- [#1847](https://github.com/oauth2-proxy/oauth2-proxy/pull/1847) Update go-redis/redis to v9 (@arhamGH)
+- 
 # V7.3.0
 
 ## Release Highlights
@@ -36,7 +65,7 @@ If you are using an architecture specific tag (ex: v7.2.1-arm64) you should move
 
 ## Breaking Changes
 
-N/A
+- [#1666](https://github.com/oauth2-proxy/oauth2-proxy/issues/1666) Azure provider breaks after upgrading to this version. Please see the issue for more details.
 
 ## Changes since v7.2.1
 
diff --git a/Dockerfile b/Dockerfile
index 7097b3ca..847c7415 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,11 @@
 # This ARG has to be at the top, otherwise the docker daemon does not known what to do with FROM ${RUNTIME_IMAGE}
-ARG RUNTIME_IMAGE=alpine:3.15
+ARG RUNTIME_IMAGE=alpine:3.16
 
 # All builds should be done using the platform native to the build node to allow
 #  cache sharing of the go mod download step.
 # Go cross compilation is also faster than emulation the go compilation across
 #  multiple platforms.
-FROM --platform=${BUILDPLATFORM} golang:1.17-buster AS builder
+FROM --platform=${BUILDPLATFORM} golang:1.19-buster AS builder
 
 # Copy sources
 WORKDIR $GOPATH/src/github.com/oauth2-proxy/oauth2-proxy
diff --git a/Makefile b/Makefile
index 6bf7b193..7ad59551 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ REGISTRY ?= quay.io/oauth2-proxy
 GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
 GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
 MINIMUM_SUPPORTED_GO_MAJOR_VERSION = 1
-MINIMUM_SUPPORTED_GO_MINOR_VERSION = 15
+MINIMUM_SUPPORTED_GO_MINOR_VERSION = 18
 GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MINIMUM_SUPPORTED_GO_MAJOR_VERSION).$(MINIMUM_SUPPORTED_GO_MINOR_VERSION)
 
 ifeq ($(COVER),true)
diff --git a/contrib/local-environment/kubernetes/values.yaml b/contrib/local-environment/kubernetes/values.yaml
index 7b69845f..9181266f 100644
--- a/contrib/local-environment/kubernetes/values.yaml
+++ b/contrib/local-environment/kubernetes/values.yaml
@@ -43,7 +43,7 @@ oauth2-proxy:
   config:
     configFile: |-
       cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w="
-      cookie_domain=".localtest.me"
+      cookie_domains=".localtest.me"
       whitelist_domains=[".localtest.me"]
       # only users with this domain will be let in
       email_domains=["example.com"]
diff --git a/contrib/oauth2-proxy.cfg.example b/contrib/oauth2-proxy.cfg.example
index cb0e8dcb..216c9c9d 100644
--- a/contrib/oauth2-proxy.cfg.example
+++ b/contrib/oauth2-proxy.cfg.example
@@ -69,6 +69,11 @@
 #   "^/metrics"
 # ]
 
+## mark paths as API routes to get HTTP Status code 401 instead of redirect to login page
+# api_routes = [
+#   "^/api
+# ]
+
 ## Templates
 ## optional directory with custom sign_in.html and error.html
 # custom_templates_dir = ""
diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md
index f0e9d182..84dac78d 100644
--- a/docs/docs/configuration/alpha_config.md
+++ b/docs/docs/configuration/alpha_config.md
@@ -164,6 +164,7 @@ They may change between releases without notice.
 | Field | Type | Description |
 | ----- | ---- | ----------- |
 | `tenant` | _string_ | Tenant directs to a tenant-specific or common (tenant-independent) endpoint<br/>Default value is 'common' |
+| `graphGroupField` | _string_ | GraphGroupField configures the group field to be used when building the groups list from Microsoft Graph<br/>Default value is 'id' |
 
 ### BitbucketOptions
 
@@ -306,7 +307,7 @@ the caller provides it, and no value will be sent otherwise.
 
 Examples:
 
-A parameter whose value is fixed
+# A parameter whose value is fixed
 
 ```
 name: organization
@@ -354,8 +355,9 @@ as backslash is not considered to be an escape character.  Alternatively
 use the "chomped block" format `|-`:
 
 ```
-- pattern: |-
+  - pattern: |-
     ^[^@]*@example\.com$
+
 ```
 
 The hyphen is important, a `|` block would have a trailing newline
@@ -419,7 +421,7 @@ Provider holds all configuration for a single provider
 | `validateURL` | _string_ | ValidateURL is the access token validation endpoint |
 | `scope` | _string_ | Scope is the OAuth scope specification |
 | `allowedGroups` | _[]string_ | AllowedGroups is a list of restrict logins to members of this group |
-| `force_code_challenge_method` | _string_ | The forced code challenge method |
+| `code_challenge_method` | _string_ | The code challenge method |
 
 ### ProviderType
 #### (`string` alias)
@@ -478,6 +480,7 @@ as well as an optional minimal TLS version that is acceptable.
 | `Key` | _[SecretSource](#secretsource)_ | Key is the TLS key data to use.<br/>Typically this will come from a file. |
 | `Cert` | _[SecretSource](#secretsource)_ | Cert is the TLS certificate data to use.<br/>Typically this will come from a file. |
 | `MinVersion` | _string_ | MinVersion is the minimal TLS version that is acceptable.<br/>E.g. Set to "TLS1.3" to select TLS version 1.3 |
+| `CipherSuites` | _[]string_ | CipherSuites is a list of TLS cipher suites that are allowed.<br/>E.g.:<br/>- TLS_RSA_WITH_RC4_128_SHA<br/>- TLS_RSA_WITH_AES_256_GCM_SHA384<br/>If not specified, the default Go safe cipher list is used.<br/>List of valid cipher suites can be found in the [crypto/tls documentation](https://pkg.go.dev/crypto/tls#pkg-constants). |
 
 ### URLParameterRule
 
diff --git a/docs/docs/configuration/auth.md b/docs/docs/configuration/auth.md
index 2617e380..4e9b41eb 100644
--- a/docs/docs/configuration/auth.md
+++ b/docs/docs/configuration/auth.md
@@ -33,7 +33,7 @@ For Google, the registration steps are:
 
 1.  Create a new project: https://console.developers.google.com/project
 2.  Choose the new project from the top right project dropdown (only if another project is selected)
-3.  In the project Dashboard center pane, choose **"API Manager"**
+3.  In the project Dashboard center pane, choose **"APIs & Services"**
 4.  In the left Nav pane, choose **"Credentials"**
 5.  In the center pane, choose **"OAuth consent screen"** tab. Fill in **"Product name shown to users"** and hit save.
 6.  In the center pane, choose **"Credentials"** tab.
@@ -72,22 +72,45 @@ Note: The user is checked against the group members list on initial authenticati
 
 ### Azure Auth Provider
 
-1. Add an application: go to [https://portal.azure.com](https://portal.azure.com), choose **"Azure Active Directory"** in the left menu, select **"App registrations"** and then click on **"New app registration"**.
-2. Pick a name and choose **"Webapp / API"** as application type. Use `https://internal.yourcompany.com` as Sign-on URL. Click **"Create"**.
-3. On the **"Settings"** / **"Properties"** page of the app, pick a logo and select **"Multi-tenanted"** if you want to allow users from multiple organizations to access your app. Note down the application ID. Click **"Save"**.
-4. On the **"Settings"** / **"Required Permissions"** page of the app, click on **"Windows Azure Active Directory"** and then on **"Access the directory as the signed in user"**. Hit **"Save"** and then then on **"Grant permissions"** (you might need another admin to do this).
-5. On the **"Settings"** / **"Reply URLs"** page of the app, add `https://internal.yourcompanycom/oauth2/callback` for each host that you want to protect by the oauth2 proxy. Click **"Save"**.
-6. On the **"Settings"** / **"Keys"** page of the app, add a new key and note down the value after hitting **"Save"**.
-7. Configure the proxy with
+1. Add an application: go to [https://portal.azure.com](https://portal.azure.com), choose **Azure Active Directory**, select 
+**App registrations** and then click on **New registration**.
+2. Pick a name, check the supported account type(single-tenant, multi-tenant, etc). In the **Redirect URI** section create a new 
+**Web** platform entry for each app that you want to protect by the oauth2 proxy(e.g. 
+https://internal.yourcompanycom/oauth2/callback). Click **Register**.
+3. Next we need to add group read permissions for the app registration, on the **API Permissions** page of the app, click on
+**Add a permission**, select **Microsoft Graph**, then select **Application permissions**, then click on **Group** and select
+**Group.Read.All**. Hit **Add permissions** and then on **Grant admin consent** (you might need an admin to do this).
+<br/>**IMPORTANT**: Even if this permission is listed with **"Admin consent required=No"** the consent might actually be required, due to AAD policies you won't be able to see. If you get a **"Need admin approval"** during login, most likely this is what you're missing!
+4. Next, if you are planning to use v2.0 Azure Auth endpoint, go to the **Manifest** page and set `"accessTokenAcceptedVersion": 2`
+in the App registration manifest file.
+5. On the **Certificates & secrets** page of the app, add a new client secret and note down the value after hitting **Add**.
+6. Configure the proxy with:
+- for V1 Azure Auth endpoint (Azure Active Directory Endpoints - https://login.microsoftonline.com/common/oauth2/authorize)
 
 ```
    --provider=azure
    --client-id=<application ID from step 3>
-   --client-secret=<value from step 6>
+   --client-secret=<value from step 5>
+   --azure-tenant={tenant-id}
    --oidc-issuer-url=https://sts.windows.net/{tenant-id}/
 ```
 
-Note: When using the Azure Auth provider with nginx and the cookie session store you may find the cookie is too large and doesn't get passed through correctly. Increasing the proxy_buffer_size in nginx or implementing the [redis session storage](sessions.md#redis-storage) should resolve this.
+- for V2 Azure Auth endpoint (Microsoft Identity Platform Endpoints - https://login.microsoftonline.com/common/oauth2/v2.0/authorize)
+```
+   --provider=azure
+   --client-id=<application ID from step 3>
+   --client-secret=<value from step 5>
+   --azure-tenant={tenant-id}
+   --oidc-issuer-url=https://login.microsoftonline.com/{tenant-id}/v2.0
+```
+
+***Notes***:
+- When using v2.0 Azure Auth endpoint (`https://login.microsoftonline.com/{tenant-id}/v2.0`) as `--oidc_issuer_url`, in conjunction 
+with `--resource` flag, be sure to append `/.default` at the end of the resource name. See
+https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#the-default-scope for more details.
+- When using the Azure Auth provider with nginx and the cookie session store you may find the cookie is too large and doesn't 
+get passed through correctly. Increasing the proxy_buffer_size in nginx or implementing the [redis session storage](sessions.md#redis-storage) 
+should resolve this.
 
 ### ADFS Auth Provider
 
@@ -192,7 +215,7 @@ Make sure you set the following to the appropriate url:
     --client-id=<your client's id>
     --client-secret=<your client's secret>
     --redirect-url=https://myapp.com/oauth2/callback
-    --oidc-issuer-url=https://<keycloak host>/realms/<your realm>
+    --oidc-issuer-url=https://<keycloak host>/auth/realms/<your realm>
     --allowed-role=<realm role name> // Optional, required realm role
     --allowed-role=<client id>:<client role name> // Optional, required client role
 ```
diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md
index bf36bd59..10e0d810 100644
--- a/docs/docs/configuration/overview.md
+++ b/docs/docs/configuration/overview.md
@@ -75,6 +75,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | Option | Type | Description | Default |
 | ------ | ---- | ----------- | ------- |
 | `--acr-values` | string | optional, see [docs](https://openid.net/specs/openid-connect-eap-acr-values-1_0.html#acrValues) | `""` |
+| `--api-route` | string \| list | return HTTP 401 instead of redirecting to authentication server if token is not valid. Format: path_regex | |
 | `--approval-prompt` | string | OAuth approval_prompt | `"force"` |
 | `--auth-logging` | bool | Log authentication attempts | true |
 | `--auth-logging-format` | string | Template for authentication log lines | see [Logging Configuration](#logging-configuration) |
@@ -89,12 +90,14 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--cookie-domain` | string \| list | Optional cookie domains to force cookies to (e.g. `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match). | |
 | `--cookie-expire` | duration | expire timeframe for cookie | 168h0m0s |
 | `--cookie-httponly` | bool | set HttpOnly cookie flag | true |
-| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates | `"_oauth2_proxy"` |
+| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates. Should be changed to use a [cookie prefix](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes) (`__Host-` or `__Secure-`) if `--cookie-secure` is set. | `"_oauth2_proxy"` |
 | `--cookie-path` | string | an optional cookie path to force cookies to (e.g. `/poc/`) | `"/"` |
 | `--cookie-refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers&nbsp;\[[1](#footnote1)\] | |
 | `--cookie-secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
 | `--cookie-secure` | bool | set [secure (HTTPS only) cookie flag](https://owasp.org/www-community/controls/SecureFlag) | true |
 | `--cookie-samesite` | string | set SameSite cookie attribute (`"lax"`, `"strict"`, `"none"`, or `""`). | `""` |
+| `--cookie-csrf-per-request` | bool | Enable having different CSRF cookies per request, making it possible to have parallel requests. | false |
+| `--cookie-csrf-expire` | duration | expire timeframe for CSRF cookie | 15m |
 | `--custom-templates-dir` | string | path to custom html templates | |
 | `--custom-sign-in-logo` | string | path or a URL to an custom image for the sign_in page logo. Use \"-\" to disable default logo. |
 | `--display-htpasswd-form` | bool | display username / password login form if an htpasswd file is provided | true |
@@ -184,7 +187,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--silence-ping-logging` | bool | disable logging of requests to ping endpoint | false |
 | `--skip-auth-preflight` | bool | will skip authentication for OPTIONS requests | false |
 | `--skip-auth-regex` | string \| list | (DEPRECATED for `--skip-auth-route`) bypass authentication for requests paths that match (may be given multiple times) | |
-| `--skip-auth-route` | string \| list | bypass authentication for requests that match the method & path. Format: method=path_regex OR path_regex alone for all methods | |
+| `--skip-auth-route` | string \| list | bypass authentication for requests that match the method & path. Format: method=path_regex OR method!=path_regex. For all methods: path_regex OR !=path_regex  | |
 | `--skip-auth-strip-headers` | bool | strips `X-Forwarded-*` style authentication headers & `Authorization` header if they would be set by oauth2-proxy | true |
 | `--skip-jwt-bearer-tokens` | bool | will skip requests that have verified JWT bearer tokens (the token must have [`aud`](https://en.wikipedia.org/wiki/JSON_Web_Token#Standard_fields) that matches this client id or one of the extras from `extra-jwt-issuers`) | false |
 | `--skip-oidc-discovery` | bool | bypass OIDC endpoint discovery. `--login-url`, `--redeem-url` and `--oidc-jwks-url` must be configured in this case | false |
@@ -194,6 +197,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--standard-logging` | bool | Log standard runtime information | true |
 | `--standard-logging-format` | string | Template for standard log lines | see [Logging Configuration](#logging-configuration) |
 | `--tls-cert-file` | string | path to certificate file | |
+| `--tls-cipher-suite` | string \| list | Restricts TLS cipher suites used by server to those listed (e.g. TLS_RSA_WITH_RC4_128_SHA) (may be given multiple times). If not specified, the default Go safe cipher list is used. List of valid cipher suites can be found in the [crypto/tls documentation](https://pkg.go.dev/crypto/tls#pkg-constants). | |
 | `--tls-key-file` | string | path to private key file | |
 | `--tls-min-version` | string | minimum TLS version that is acceptable, either `"TLS1.2"` or `"TLS1.3"` | `"TLS1.2"` |
 | `--upstream` | string \| list | the http url(s) of the upstream endpoint, file:// paths for static files or `static://<status_code>` for static response. Routing is based on the path | |
@@ -595,4 +599,4 @@ http:
 
 :::note
 If you set up your OAuth2 provider to rotate your client secret, you can use the `client-secret-file` option to reload the secret when it is updated.
-:::
\ No newline at end of file
+:::
diff --git a/docs/docs/configuration/tls.md b/docs/docs/configuration/tls.md
index 1efdc5fa..21aa6f3b 100644
--- a/docs/docs/configuration/tls.md
+++ b/docs/docs/configuration/tls.md
@@ -32,8 +32,9 @@ There are two recommended configurations:
     The defaults set `TLS1.2` as the minimal version. 
     Regardless of the minimum version configured, `TLS1.3` is currently always used as the maximal version.
 
-    The server side cipher suites are the defaults from [`crypto/tls`](https://pkg.go.dev/crypto/tls#CipherSuites) of 
-    the currently used `go` version for building `oauth2-proxy`.
+    TLS server side cipher suites can be specified with `--tls-cipher-suite=TLS_RSA_WITH_RC4_128_SHA`.
+    If not specified, the defaults from [`crypto/tls`](https://pkg.go.dev/crypto/tls#CipherSuites) of the currently used `go` version for building `oauth2-proxy` will be used.
+    A complete list of valid TLS cipher suite names can be found in [`crypto/tls`](https://pkg.go.dev/crypto/tls#pkg-constants).
 
 ### Terminate TLS at Reverse Proxy, e.g. Nginx
 
diff --git a/docs/docs/features/endpoints.md b/docs/docs/features/endpoints.md
index 61e1107b..bacb5439 100644
--- a/docs/docs/features/endpoints.md
+++ b/docs/docs/features/endpoints.md
@@ -33,7 +33,7 @@ X-Auth-Request-Redirect: https://my-oidc-provider/sign_out_page
 
 (The "sign_out_page" should be the [`end_session_endpoint`](https://openid.net/specs/openid-connect-session-1_0.html#rfc.section.2.1) from [the metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) if your OIDC provider supports Session Management and Discovery.)
 
-BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` in the example) must be added to the [`--whitelist-domain`](../configuration/overview) configuration option otherwise the redirect will be ignored.
+BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` in the example) must be added to the [`--whitelist-domain`](../configuration/overview) configuration option otherwise the redirect will be ignored. Make sure to include the actual domain and port (if needed) and not the URL (e.g "localhost:8081" instead of "http://localhost:8081").
 
 ### Auth
 
diff --git a/docs/versioned_docs/version-6.1.x/configuration/overview.md b/docs/versioned_docs/version-6.1.x/configuration/overview.md
index 5c0cbf95..6810be75 100644
--- a/docs/versioned_docs/version-6.1.x/configuration/overview.md
+++ b/docs/versioned_docs/version-6.1.x/configuration/overview.md
@@ -88,7 +88,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--cookie-domain` | string \| list | Optional cookie domains to force cookies to (e.g. `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match). | |
 | `--cookie-expire` | duration | expire timeframe for cookie | 168h0m0s |
 | `--cookie-httponly` | bool | set HttpOnly cookie flag | true |
-| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates | `"_oauth2_proxy"` |
+| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates. Should be changed to use a [cookie prefix](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes) (`__Host-` or `__Secure-`) if `--cookie-secure` is set. | `"_oauth2_proxy"` |
 | `--cookie-path` | string | an optional cookie path to force cookies to (e.g. `/poc/`) | `"/"` |
 | `--cookie-refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers&nbsp;\[[1](#footnote1)\] | |
 | `--cookie-secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
diff --git a/docs/versioned_docs/version-7.0.x/configuration/overview.md b/docs/versioned_docs/version-7.0.x/configuration/overview.md
index b77a5517..ed433edd 100644
--- a/docs/versioned_docs/version-7.0.x/configuration/overview.md
+++ b/docs/versioned_docs/version-7.0.x/configuration/overview.md
@@ -88,7 +88,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--cookie-domain` | string \| list | Optional cookie domains to force cookies to (e.g. `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match). | |
 | `--cookie-expire` | duration | expire timeframe for cookie | 168h0m0s |
 | `--cookie-httponly` | bool | set HttpOnly cookie flag | true |
-| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates | `"_oauth2_proxy"` |
+| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates. Should be changed to use a [cookie prefix](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes) (`__Host-` or `__Secure-`) if `--cookie-secure` is set. | `"_oauth2_proxy"` |
 | `--cookie-path` | string | an optional cookie path to force cookies to (e.g. `/poc/`) | `"/"` |
 | `--cookie-refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers&nbsp;\[[1](#footnote1)\] | |
 | `--cookie-secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
diff --git a/docs/versioned_docs/version-7.1.x/configuration/overview.md b/docs/versioned_docs/version-7.1.x/configuration/overview.md
index 9a2113e7..3121e6b2 100644
--- a/docs/versioned_docs/version-7.1.x/configuration/overview.md
+++ b/docs/versioned_docs/version-7.1.x/configuration/overview.md
@@ -33,7 +33,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--cookie-domain` | string \| list | Optional cookie domains to force cookies to (e.g. `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match). | |
 | `--cookie-expire` | duration | expire timeframe for cookie | 168h0m0s |
 | `--cookie-httponly` | bool | set HttpOnly cookie flag | true |
-| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates | `"_oauth2_proxy"` |
+| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates. Should be changed to use a [cookie prefix](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes) (`__Host-` or `__Secure-`) if `--cookie-secure` is set. | `"_oauth2_proxy"` |
 | `--cookie-path` | string | an optional cookie path to force cookies to (e.g. `/poc/`) | `"/"` |
 | `--cookie-refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers&nbsp;\[[1](#footnote1)\] | |
 | `--cookie-secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
diff --git a/docs/versioned_docs/version-7.2.x/configuration/overview.md b/docs/versioned_docs/version-7.2.x/configuration/overview.md
index 092fb7dc..9ca8f036 100644
--- a/docs/versioned_docs/version-7.2.x/configuration/overview.md
+++ b/docs/versioned_docs/version-7.2.x/configuration/overview.md
@@ -88,7 +88,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--cookie-domain` | string \| list | Optional cookie domains to force cookies to (e.g. `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match). | |
 | `--cookie-expire` | duration | expire timeframe for cookie | 168h0m0s |
 | `--cookie-httponly` | bool | set HttpOnly cookie flag | true |
-| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates | `"_oauth2_proxy"` |
+| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates. Should be changed to use a [cookie prefix](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes) (`__Host-` or `__Secure-`) if `--cookie-secure` is set. | `"_oauth2_proxy"` |
 | `--cookie-path` | string | an optional cookie path to force cookies to (e.g. `/poc/`) | `"/"` |
 | `--cookie-refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers&nbsp;\[[1](#footnote1)\] | |
 | `--cookie-secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
diff --git a/docs/versioned_docs/version-7.3.x/configuration/overview.md b/docs/versioned_docs/version-7.3.x/configuration/overview.md
index 00c59ca5..d58a2d11 100644
--- a/docs/versioned_docs/version-7.3.x/configuration/overview.md
+++ b/docs/versioned_docs/version-7.3.x/configuration/overview.md
@@ -89,7 +89,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--cookie-domain` | string \| list | Optional cookie domains to force cookies to (e.g. `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match). | |
 | `--cookie-expire` | duration | expire timeframe for cookie | 168h0m0s |
 | `--cookie-httponly` | bool | set HttpOnly cookie flag | true |
-| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates | `"_oauth2_proxy"` |
+| `--cookie-name` | string | the name of the cookie that the oauth_proxy creates. Should be changed to use a [cookie prefix](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes) (`__Host-` or `__Secure-`) if `--cookie-secure` is set. | `"_oauth2_proxy"` |
 | `--cookie-path` | string | an optional cookie path to force cookies to (e.g. `/poc/`) | `"/"` |
 | `--cookie-refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers&nbsp;\[[1](#footnote1)\] | |
 | `--cookie-secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
diff --git a/go.mod b/go.mod
index c34e7ec1..5670f954 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +7,11 @@ require (
 	github.com/alicebob/miniredis/v2 v2.13.0
 	github.com/benbjohnson/clock v1.1.1-0.20210213131748-c97fc7b6bee0
 	github.com/bitly/go-simplejson v0.5.0
-	github.com/bsm/redislock v0.7.0
+	github.com/bsm/redislock v0.8.1
 	github.com/coreos/go-oidc/v3 v3.0.0
 	github.com/fsnotify/fsnotify v1.4.9
 	github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32
-	github.com/go-redis/redis/v8 v8.2.3
+	github.com/go-redis/redis/v9 v9.0.0-rc.1
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/google/uuid v1.2.0
 	github.com/gorilla/mux v1.8.0
@@ -20,17 +20,18 @@ require (
 	github.com/mitchellh/mapstructure v1.1.2
 	github.com/oauth2-proxy/mockoidc v0.0.0-20220221072942-e3afe97dec43
 	github.com/oauth2-proxy/tools/reference-gen v0.0.0-20210118095127-56ffd7384404
-	github.com/onsi/ginkgo v1.14.1
-	github.com/onsi/gomega v1.10.2
+	github.com/onsi/ginkgo v1.16.5
+	github.com/onsi/gomega v1.21.1
 	github.com/pierrec/lz4 v2.5.2+incompatible
 	github.com/prometheus/client_golang v1.11.1
 	github.com/spf13/cast v1.3.0
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.6.3
-	github.com/stretchr/testify v1.7.0
+	github.com/stretchr/testify v1.8.0
 	github.com/vmihailenco/msgpack/v4 v4.3.11
-	golang.org/x/crypto v0.0.0-20220214200702-86341886e292
-	golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2
+	golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b
+	golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e
+	golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	golang.org/x/sync v0.0.0-20201207232520-09787c993a3a
 	google.golang.org/api v0.20.0
@@ -44,18 +45,19 @@ require (
 	github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
-	github.com/cespare/xxhash/v2 v2.1.1 // indirect
+	github.com/cespare/xxhash/v2 v2.1.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/frankban/quicktest v1.10.0 // indirect
 	github.com/go-logr/logr v0.2.0 // indirect
 	github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7 // indirect
-	github.com/golang/protobuf v1.4.3 // indirect
+	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/google/go-cmp v0.5.8 // indirect
 	github.com/googleapis/gax-go/v2 v2.0.5 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/magiconair/properties v1.8.1 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
-	github.com/nxadm/tail v1.4.4 // indirect
+	github.com/nxadm/tail v1.4.8 // indirect
 	github.com/pelletier/go-toml v1.2.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
@@ -67,18 +69,16 @@ require (
 	github.com/vmihailenco/tagparser v0.1.1 // indirect
 	github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb // indirect
 	go.opencensus.io v0.22.2 // indirect
-	go.opentelemetry.io/otel v0.11.0 // indirect
-	golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
-	golang.org/x/text v0.3.7 // indirect
-	golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
+	golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
+	golang.org/x/text v0.3.8 // indirect
 	google.golang.org/appengine v1.6.5 // indirect
 	google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
 	google.golang.org/grpc v1.27.0 // indirect
-	google.golang.org/protobuf v1.26.0-rc.1 // indirect
+	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/ini.v1 v1.51.0 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
-	gopkg.in/yaml.v2 v2.3.0 // indirect
-	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 	k8s.io/gengo v0.0.0-20201113003025-83324d819ded // indirect
 	k8s.io/klog/v2 v2.4.0 // indirect
 )
diff --git a/go.sum b/go.sum
index bdfaeef4..590e6807 100644
--- a/go.sum
+++ b/go.sum
@@ -2,12 +2,10 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo=
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/Bose/minisentinel v0.0.0-20200130220412-917c5a9223bb h1:ZVN4Iat3runWOFLaBCDVU5a9X/XikSRBosye++6gojw=
 github.com/Bose/minisentinel v0.0.0-20200130220412-917c5a9223bb/go.mod h1:WsAABbY4HQBgd3mGuG4KMNTbHJCPvx9IVBHzysbknss=
 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/FZambia/sentinel v1.0.0 h1:KJ0ryjKTZk5WMp0dXvSdNqp3lFaW1fNFuEYfrkLOYIc=
 github.com/FZambia/sentinel v1.0.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
@@ -36,12 +34,13 @@ github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkN
 github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
-github.com/bsm/redislock v0.7.0 h1:RL7aZJhCKkuBjQbnSTKCeedTRifBWxd/ffP+GZ599Mo=
-github.com/bsm/redislock v0.7.0/go.mod h1:3Kgu+cXw0JrkZ5pmY/JbcFpixGZ5M9v9G2PGWYqku+k=
+github.com/bsm/redislock v0.8.1 h1:uQwHh+HHblBkXq+0K7ofaOlQAIYQlax4FhfHtXUlFoU=
+github.com/bsm/redislock v0.8.1/go.mod h1:6Xx3bVaIiGNEmpQdObCvTGu3V98TPmnsLLTPzWm4mcI=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -76,7 +75,6 @@ github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2H
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ERFA1PUxfmGpolnw2v0bKOREu5ew=
 github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -90,10 +88,10 @@ github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+
 github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg=
 github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc=
 github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=
-github.com/go-redis/redis/v8 v8.1.0/go.mod h1:isLoQT/NFSP7V67lyvM9GmdvLdyZ7pEhsXvvyQtnQTo=
-github.com/go-redis/redis/v8 v8.2.3 h1:eNesND+DWt/sjQOtPFxAbQkTIXaXX00qNLxjVWkZ70k=
-github.com/go-redis/redis/v8 v8.2.3/go.mod h1:ysgGY09J/QeDYbu3HikWEIPCwaeOkuNoTgKayTEaEOw=
+github.com/go-redis/redis/v9 v9.0.0-rc.1 h1:/+bS+yeUnanqAbuD3QwlejzQZ+4eqgfUtFTG4b+QnXs=
+github.com/go-redis/redis/v9 v9.0.0-rc.1/go.mod h1:8et+z03j0l8N+DvsVnclzjf3Dl/pFHgRk+2Ct1qw66A=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
@@ -119,8 +117,10 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/gomodule/redigo v1.7.1-0.20190322064113-39e2c31b7ca3/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
 github.com/gomodule/redigo v1.8.1 h1:Abmo0bI7Xf0IhdIPc7HZQzZcShdnmxeoVuDDtIQp8N8=
 github.com/gomodule/redigo v1.8.1/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
@@ -130,10 +130,10 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -202,8 +202,9 @@ github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8m
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
-github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
 github.com/oauth2-proxy/mockoidc v0.0.0-20220221072942-e3afe97dec43 h1:V9YiO92tYBmVgVcKhdxK6I4avJCefBM+0Db4WM2dank=
 github.com/oauth2-proxy/mockoidc v0.0.0-20220221072942-e3afe97dec43/go.mod h1:rW25Kyd08Wdn3UVn0YBsDTSvReu0jqpmJKzxITPSjks=
 github.com/oauth2-proxy/tools/reference-gen v0.0.0-20210118095127-56ffd7384404 h1:ZpzR4Ou1nhldBG/vEzauoqyaUlofaUcLkv1C/gBK8ls=
@@ -213,14 +214,15 @@ github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
-github.com/onsi/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4=
-github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/ginkgo/v2 v2.1.6 h1:Fx2POJZfKRQcM1pH49qSZiYeu319wji004qX+GDovrU=
 github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
 github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
-github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs=
-github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.21.1 h1:OB/euWYIExnPBohllTicTHmGTrMaqJ67nIu80j0/uEM=
+github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc=
 github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pierrec/lz4 v2.5.2+incompatible h1:WCjObylUIOlKy/+7Abdn34TLIkXiA4UWUMhxq9m9ZXI=
@@ -278,13 +280,15 @@ github.com/spf13/viper v1.6.3 h1:pDDu1OyEDTKzpJwdq4TiuLyMsUgRa/BT5cn5O62NoHs=
 github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@@ -295,6 +299,7 @@ github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgq
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
 github.com/yuin/gopher-lua v0.0.0-20191213034115-f46add6fdb5c/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
 github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb h1:ZkM6LRnq40pR1Ox0hTHlnpkcOTuFIDQpZ1IN8rKKhX0=
@@ -303,8 +308,6 @@ go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.2 h1:75k/FF0Q2YM8QYo07VPddOLBslDt1MZOdEslOHvmzAs=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opentelemetry.io/otel v0.11.0 h1:IN2tzQa9Gc4ZVKnTaMbPVcHjvzOdg5n9QfnmlqiET7E=
-go.opentelemetry.io/otel v0.11.0/go.mod h1:G8UCk+KooF2HLkgo8RHX9epABH/aRGYET7gQOqBVdB0=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
@@ -312,23 +315,20 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE=
 golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b h1:Qwe1rC8PSniVfAFPFJeyUkB+zcysC3RgJBAGk7eqBEU=
+golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e h1:SkwG94eNiiYJhbeDE018Grw09HIN/KB9NlRmZsrzfWs=
+golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449 h1:xUIPaMhvROX9dhPvRCenIJtU78+lbEenGbgqB5hfHCQ=
-golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -350,8 +350,10 @@ golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/
 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458 h1:MgJ6t2zo8v0tbmLCueaCbF1RM+TtB0rs3Lv8DGtOIpY=
+golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -363,6 +365,7 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -372,35 +375,35 @@ golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -414,13 +417,13 @@ golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3
 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8 h1:BMFHd4OFnFtWX46Xj4DN6vvT1btiBxyq+s0orYBqcQY=
 golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40=
 google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
@@ -451,8 +454,10 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -476,10 +481,12 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/oauthproxy.go b/oauthproxy.go
index fd136271..d11040c3 100644
--- a/oauthproxy.go
+++ b/oauthproxy.go
@@ -64,6 +64,11 @@ var (
 // allowedRoute manages method + path based allowlists
 type allowedRoute struct {
 	method    string
+	negate    bool
+	pathRegex *regexp.Regexp
+}
+
+type apiRoute struct {
 	pathRegex *regexp.Regexp
 }
 
@@ -75,6 +80,7 @@ type OAuthProxy struct {
 	SignInPath string
 
 	allowedRoutes       []allowedRoute
+	apiRoutes           []apiRoute
 	redirectURL         *url.URL // the url to receive requests at
 	whitelistDomains    []string
 	provider            providers.Provider
@@ -113,7 +119,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
 		var err error
 		basicAuthValidator, err = basic.NewHTPasswdValidator(opts.HtpasswdFile)
 		if err != nil {
-			return nil, fmt.Errorf("could not load htpasswdfile: %v", err)
+			return nil, fmt.Errorf("could not validate htpasswd: %v", err)
 		}
 	}
 
@@ -175,6 +181,11 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
 		return nil, err
 	}
 
+	apiRoutes, err := buildAPIRoutes(opts)
+	if err != nil {
+		return nil, err
+	}
+
 	preAuthChain, err := buildPreAuthChain(opts)
 	if err != nil {
 		return nil, fmt.Errorf("could not build pre-auth chain: %v", err)
@@ -201,6 +212,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
 		provider:            provider,
 		sessionStore:        sessionStore,
 		redirectURL:         redirectURL,
+		apiRoutes:           apiRoutes,
 		allowedRoutes:       allowedRoutes,
 		whitelistDomains:    opts.WhitelistDomains,
 		skipAuthPreflight:   opts.SkipAuthPreflight,
@@ -445,9 +457,10 @@ func buildRoutesAllowlist(opts *options.Options) ([]allowedRoute, error) {
 		var (
 			method string
 			path   string
+			negate = strings.Contains(methodPath, "!=")
 		)
 
-		parts := strings.SplitN(methodPath, "=", 2)
+		parts := regexp.MustCompile("!?=").Split(methodPath, 2)
 		if len(parts) == 1 {
 			method = ""
 			path = parts[0]
@@ -463,6 +476,25 @@ func buildRoutesAllowlist(opts *options.Options) ([]allowedRoute, error) {
 		logger.Printf("Skipping auth - Method: %s | Path: %s", method, path)
 		routes = append(routes, allowedRoute{
 			method:    method,
+			negate:    negate,
+			pathRegex: compiledRegex,
+		})
+	}
+
+	return routes, nil
+}
+
+// buildAPIRoutes builds an []apiRoute from ApiRoutes option
+func buildAPIRoutes(opts *options.Options) ([]apiRoute, error) {
+	routes := make([]apiRoute, 0, len(opts.APIRoutes))
+
+	for _, path := range opts.APIRoutes {
+		compiledRegex, err := regexp.Compile(path)
+		if err != nil {
+			return nil, err
+		}
+		logger.Printf("API route - Path: %s", path)
+		routes = append(routes, apiRoute{
 			pathRegex: compiledRegex,
 		})
 	}
@@ -516,10 +548,33 @@ func (p *OAuthProxy) IsAllowedRequest(req *http.Request) bool {
 	return isPreflightRequestAllowed || p.isAllowedRoute(req) || p.isTrustedIP(req)
 }
 
+func isAllowedMethod(req *http.Request, route allowedRoute) bool {
+	return route.method == "" || req.Method == route.method
+}
+
+func isAllowedPath(req *http.Request, route allowedRoute) bool {
+	matches := route.pathRegex.MatchString(req.URL.Path)
+
+	if route.negate {
+		return !matches
+	}
+
+	return matches
+}
+
 // IsAllowedRoute is used to check if the request method & path is allowed without auth
 func (p *OAuthProxy) isAllowedRoute(req *http.Request) bool {
 	for _, route := range p.allowedRoutes {
-		if (route.method == "" || req.Method == route.method) && route.pathRegex.MatchString(req.URL.Path) {
+		if isAllowedMethod(req, route) && isAllowedPath(req, route) {
+			return true
+		}
+	}
+	return false
+}
+
+func (p *OAuthProxy) isAPIPath(req *http.Request) bool {
+	for _, route := range p.apiRoutes {
+		if route.pathRegex.MatchString(req.URL.Path) {
 			return true
 		}
 	}
@@ -894,7 +949,7 @@ func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
 		p.headersChain.Then(p.upstreamProxy).ServeHTTP(rw, req)
 	case ErrNeedsLogin:
 		// we need to send the user to a login screen
-		if p.forceJSONErrors || isAjax(req) {
+		if p.forceJSONErrors || isAjax(req) || p.isAPIPath(req) {
 			logger.Printf("No valid authentication in request. Access Denied.")
 			// no point redirecting an AJAX request
 			p.errorJSON(rw, http.StatusUnauthorized)
diff --git a/pkg/apis/options/cookie.go b/pkg/apis/options/cookie.go
index 46ae9c08..6917bdc5 100644
--- a/pkg/apis/options/cookie.go
+++ b/pkg/apis/options/cookie.go
@@ -8,15 +8,17 @@ import (
 
 // Cookie contains configuration options relating to Cookie configuration
 type Cookie struct {
-	Name     string        `flag:"cookie-name" cfg:"cookie_name"`
-	Secret   string        `flag:"cookie-secret" cfg:"cookie_secret"`
-	Domains  []string      `flag:"cookie-domain" cfg:"cookie_domains"`
-	Path     string        `flag:"cookie-path" cfg:"cookie_path"`
-	Expire   time.Duration `flag:"cookie-expire" cfg:"cookie_expire"`
-	Refresh  time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh"`
-	Secure   bool          `flag:"cookie-secure" cfg:"cookie_secure"`
-	HTTPOnly bool          `flag:"cookie-httponly" cfg:"cookie_httponly"`
-	SameSite string        `flag:"cookie-samesite" cfg:"cookie_samesite"`
+	Name           string        `flag:"cookie-name" cfg:"cookie_name"`
+	Secret         string        `flag:"cookie-secret" cfg:"cookie_secret"`
+	Domains        []string      `flag:"cookie-domain" cfg:"cookie_domains"`
+	Path           string        `flag:"cookie-path" cfg:"cookie_path"`
+	Expire         time.Duration `flag:"cookie-expire" cfg:"cookie_expire"`
+	Refresh        time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh"`
+	Secure         bool          `flag:"cookie-secure" cfg:"cookie_secure"`
+	HTTPOnly       bool          `flag:"cookie-httponly" cfg:"cookie_httponly"`
+	SameSite       string        `flag:"cookie-samesite" cfg:"cookie_samesite"`
+	CSRFPerRequest bool          `flag:"cookie-csrf-per-request" cfg:"cookie_csrf_per_request"`
+	CSRFExpire     time.Duration `flag:"cookie-csrf-expire" cfg:"cookie_csrf_expire"`
 }
 
 func cookieFlagSet() *pflag.FlagSet {
@@ -31,21 +33,24 @@ func cookieFlagSet() *pflag.FlagSet {
 	flagSet.Bool("cookie-secure", true, "set secure (HTTPS) cookie flag")
 	flagSet.Bool("cookie-httponly", true, "set HttpOnly cookie flag")
 	flagSet.String("cookie-samesite", "", "set SameSite cookie attribute (ie: \"lax\", \"strict\", \"none\", or \"\"). ")
-
+	flagSet.Bool("cookie-csrf-per-request", false, "When this property is set to true, then the CSRF cookie name is built based on the state and varies per request. If property is set to false, then CSRF cookie has the same name for all requests.")
+	flagSet.Duration("cookie-csrf-expire", time.Duration(15)*time.Minute, "expire timeframe for CSRF cookie")
 	return flagSet
 }
 
 // cookieDefaults creates a Cookie populating each field with its default value
 func cookieDefaults() Cookie {
 	return Cookie{
-		Name:     "_oauth2_proxy",
-		Secret:   "",
-		Domains:  nil,
-		Path:     "/",
-		Expire:   time.Duration(168) * time.Hour,
-		Refresh:  time.Duration(0),
-		Secure:   true,
-		HTTPOnly: true,
-		SameSite: "",
+		Name:           "_oauth2_proxy",
+		Secret:         "",
+		Domains:        nil,
+		Path:           "/",
+		Expire:         time.Duration(168) * time.Hour,
+		Refresh:        time.Duration(0),
+		Secure:         true,
+		HTTPOnly:       true,
+		SameSite:       "",
+		CSRFPerRequest: false,
+		CSRFExpire:     time.Duration(15) * time.Minute,
 	}
 }
diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go
index 16297e00..3de9786b 100644
--- a/pkg/apis/options/legacy_options.go
+++ b/pkg/apis/options/legacy_options.go
@@ -447,15 +447,16 @@ func getXAuthRequestAccessTokenHeader() Header {
 }
 
 type LegacyServer struct {
-	MetricsAddress       string `flag:"metrics-address" cfg:"metrics_address"`
-	MetricsSecureAddress string `flag:"metrics-secure-address" cfg:"metrics_secure_address"`
-	MetricsTLSCertFile   string `flag:"metrics-tls-cert-file" cfg:"metrics_tls_cert_file"`
-	MetricsTLSKeyFile    string `flag:"metrics-tls-key-file" cfg:"metrics_tls_key_file"`
-	HTTPAddress          string `flag:"http-address" cfg:"http_address"`
-	HTTPSAddress         string `flag:"https-address" cfg:"https_address"`
-	TLSCertFile          string `flag:"tls-cert-file" cfg:"tls_cert_file"`
-	TLSKeyFile           string `flag:"tls-key-file" cfg:"tls_key_file"`
-	TLSMinVersion        string `flag:"tls-min-version" cfg:"tls_min_version"`
+	MetricsAddress       string   `flag:"metrics-address" cfg:"metrics_address"`
+	MetricsSecureAddress string   `flag:"metrics-secure-address" cfg:"metrics_secure_address"`
+	MetricsTLSCertFile   string   `flag:"metrics-tls-cert-file" cfg:"metrics_tls_cert_file"`
+	MetricsTLSKeyFile    string   `flag:"metrics-tls-key-file" cfg:"metrics_tls_key_file"`
+	HTTPAddress          string   `flag:"http-address" cfg:"http_address"`
+	HTTPSAddress         string   `flag:"https-address" cfg:"https_address"`
+	TLSCertFile          string   `flag:"tls-cert-file" cfg:"tls_cert_file"`
+	TLSKeyFile           string   `flag:"tls-key-file" cfg:"tls_key_file"`
+	TLSMinVersion        string   `flag:"tls-min-version" cfg:"tls_min_version"`
+	TLSCipherSuites      []string `flag:"tls-cipher-suite" cfg:"tls_cipher_suites"`
 }
 
 func legacyServerFlagset() *pflag.FlagSet {
@@ -470,6 +471,7 @@ func legacyServerFlagset() *pflag.FlagSet {
 	flagSet.String("tls-cert-file", "", "path to certificate file")
 	flagSet.String("tls-key-file", "", "path to private key file")
 	flagSet.String("tls-min-version", "", "minimal TLS version for HTTPS clients (either \"TLS1.2\" or \"TLS1.3\")")
+	flagSet.StringSlice("tls-cipher-suite", []string{}, "restricts TLS cipher suites to those listed (e.g. TLS_RSA_WITH_RC4_128_SHA) (may be given multiple times)")
 
 	return flagSet
 }
@@ -481,6 +483,7 @@ type LegacyProvider struct {
 
 	KeycloakGroups           []string `flag:"keycloak-group" cfg:"keycloak_groups"`
 	AzureTenant              string   `flag:"azure-tenant" cfg:"azure_tenant"`
+	AzureGraphGroupField     string   `flag:"azure-graph-group-field" cfg:"azure_graph_group_field"`
 	BitbucketTeam            string   `flag:"bitbucket-team" cfg:"bitbucket_team"`
 	BitbucketRepository      string   `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
 	GitHubOrg                string   `flag:"github-org" cfg:"github_org"`
@@ -526,7 +529,9 @@ type LegacyProvider struct {
 	JWTKeyFile string `flag:"jwt-key-file" cfg:"jwt_key_file"`
 	PubJWKURL  string `flag:"pubjwk-url" cfg:"pubjwk_url"`
 	// PKCE Code Challenge method to use (either S256 or plain)
-	CodeChallengeMethod string `flag:"code-challenge-method" cfg:"force_code_challenge_method"`
+	CodeChallengeMethod string `flag:"code-challenge-method" cfg:"code_challenge_method"`
+	// Provided for legacy reasons, to be dropped in newer version see #1667
+	ForceCodeChallengeMethod string `flag:"force-code-challenge-method" cfg:"force_code_challenge_method"`
 }
 
 func legacyProviderFlagSet() *pflag.FlagSet {
@@ -534,6 +539,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
 
 	flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)")
 	flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
+	flagSet.String("azure-graph-group-field", "", "configures the group field to be used when building the groups list(`id` or `displayName`. Default is `id`) from Microsoft Graph(available only for v2.0 oidc url). Based on this value, the `allowed-group` config values should be adjusted accordingly. If using `id` as group field, `allowed-group` should contains groups IDs, if using `displayName` as group field, `allowed-group` should contains groups name")
 	flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
 	flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
 	flagSet.String("github-org", "", "restrict logins to members of this organisation")
@@ -572,6 +578,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
 	flagSet.String("prompt", "", "OIDC prompt")
 	flagSet.String("approval-prompt", "force", "OAuth approval_prompt")
 	flagSet.String("code-challenge-method", "", "use PKCE code challenges with the specified method. Either 'plain' or 'S256'")
+	flagSet.String("force-code-challenge-method", "", "Deprecated - use --code-challenge-method")
 
 	flagSet.String("acr-values", "", "acr values string:  optional")
 	flagSet.String("jwt-key", "", "private key in PEM format used to sign JWT, so that you can say something like -jwt-key=\"${OAUTH2_PROXY_JWT_KEY}\": required by login.gov")
@@ -600,6 +607,9 @@ func (l LegacyServer) convert() (Server, Server) {
 			},
 			MinVersion: l.TLSMinVersion,
 		}
+		if len(l.TLSCipherSuites) != 0 {
+			appServer.TLS.CipherSuites = l.TLSCipherSuites
+		}
 		// Preserve backwards compatibility, only run one server
 		appServer.BindAddress = ""
 	} else {
@@ -660,10 +670,16 @@ func (l *LegacyProvider) convert() (Providers, error) {
 		ExtraAudiences:                 l.OIDCExtraAudiences,
 	}
 
+	// Support for legacy configuration option
+	if l.ForceCodeChallengeMethod != "" && l.CodeChallengeMethod == "" {
+		provider.CodeChallengeMethod = l.ForceCodeChallengeMethod
+	}
+
 	// This part is out of the switch section because azure has a default tenant
 	// that needs to be added from legacy options
 	provider.AzureConfig = AzureOptions{
-		Tenant: l.AzureTenant,
+		Tenant:          l.AzureTenant,
+		GraphGroupField: l.AzureGraphGroupField,
 	}
 
 	switch provider.Type {
diff --git a/pkg/apis/options/load.go b/pkg/apis/options/load.go
index 9ead8a3b..ec39cd5c 100644
--- a/pkg/apis/options/load.go
+++ b/pkg/apis/options/load.go
@@ -3,7 +3,7 @@ package options
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"os"
 	"reflect"
 	"strings"
 
@@ -17,7 +17,9 @@ import (
 // variables (prefixed with `OAUTH2_PROXY`) and finally merges in flags from the flagSet.
 // If a config value is unset and the flag has a non-zero value default, this default will be used.
 // Eg. A field defined:
-//    FooBar `cfg:"foo_bar" flag:"foo-bar"`
+//
+//	FooBar `cfg:"foo_bar" flag:"foo-bar"`
+//
 // Can be set in the config file as `foo_bar="baz"`, in the environment as `OAUTH2_PROXY_FOO_BAR=baz`,
 // or via the command line flag `--foo-bar=baz`.
 func Load(configFileName string, flagSet *pflag.FlagSet, into interface{}) error {
@@ -147,7 +149,7 @@ func LoadYAML(configFileName string, into interface{}) error {
 		return errors.New("no configuration file provided")
 	}
 
-	data, err := ioutil.ReadFile(configFileName)
+	data, err := os.ReadFile(configFileName)
 	if err != nil {
 		return fmt.Errorf("unable to load config file: %w", err)
 	}
diff --git a/pkg/apis/options/login_url_parameters.go b/pkg/apis/options/login_url_parameters.go
index 2ebe550a..1cb763b9 100644
--- a/pkg/apis/options/login_url_parameters.go
+++ b/pkg/apis/options/login_url_parameters.go
@@ -14,7 +14,7 @@ package options
 //
 // Examples:
 //
-// A parameter whose value is fixed
+// # A parameter whose value is fixed
 //
 // ```
 // name: organization
@@ -62,8 +62,9 @@ package options
 // use the "chomped block" format `|-`:
 //
 // ```
-// - pattern: |-
+//   - pattern: |-
 //     ^[^@]*@example\.com$
+//
 // ```
 //
 // The hyphen is important, a `|` block would have a trailing newline
diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go
index eac42ead..c65f1244 100644
--- a/pkg/apis/options/options.go
+++ b/pkg/apis/options/options.go
@@ -50,6 +50,7 @@ type Options struct {
 
 	Providers Providers `cfg:",internal"`
 
+	APIRoutes             []string `flag:"api-route" cfg:"api_routes"`
 	SkipAuthRegex         []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
 	SkipAuthRoutes        []string `flag:"skip-auth-route" cfg:"skip_auth_routes"`
 	SkipJwtBearerTokens   bool     `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"`
@@ -115,7 +116,8 @@ func NewFlagSet() *pflag.FlagSet {
 	flagSet.Bool("force-https", false, "force HTTPS redirect for HTTP requests")
 	flagSet.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"")
 	flagSet.StringSlice("skip-auth-regex", []string{}, "(DEPRECATED for --skip-auth-route) bypass authentication for requests path's that match (may be given multiple times)")
-	flagSet.StringSlice("skip-auth-route", []string{}, "bypass authentication for requests that match the method & path. Format: method=path_regex OR path_regex alone for all methods")
+	flagSet.StringSlice("skip-auth-route", []string{}, "bypass authentication for requests that match the method & path. Format: method=path_regex OR method!=path_regex. For all methods: path_regex OR !=path_regex")
+	flagSet.StringSlice("api-route", []string{}, "return HTTP 401 instead of redirecting to authentication server if token is not valid. Format: path_regex")
 	flagSet.Bool("skip-provider-button", false, "will skip sign-in-page to directly reach the next step: oauth/start")
 	flagSet.Bool("skip-auth-preflight", false, "will skip authentication for OPTIONS requests")
 	flagSet.Bool("ssl-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS providers")
diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go
index 775ce618..b559e438 100644
--- a/pkg/apis/options/providers.go
+++ b/pkg/apis/options/providers.go
@@ -76,8 +76,8 @@ type Provider struct {
 	Scope string `json:"scope,omitempty"`
 	// AllowedGroups is a list of restrict logins to members of this group
 	AllowedGroups []string `json:"allowedGroups,omitempty"`
-	// The forced code challenge method
-	CodeChallengeMethod string `json:"force_code_challenge_method,omitempty"`
+	// The code challenge method
+	CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
 }
 
 // ProviderType is used to enumerate the different provider type options
@@ -142,6 +142,9 @@ type AzureOptions struct {
 	// Tenant directs to a tenant-specific or common (tenant-independent) endpoint
 	// Default value is 'common'
 	Tenant string `json:"tenant,omitempty"`
+	// GraphGroupField configures the group field to be used when building the groups list from Microsoft Graph
+	// Default value is 'id'
+	GraphGroupField string `json:"graphGroupField,omitempty"`
 }
 
 type ADFSOptions struct {
diff --git a/pkg/apis/options/server.go b/pkg/apis/options/server.go
index 704e133e..f423ef2c 100644
--- a/pkg/apis/options/server.go
+++ b/pkg/apis/options/server.go
@@ -29,4 +29,12 @@ type TLS struct {
 	// MinVersion is the minimal TLS version that is acceptable.
 	// E.g. Set to "TLS1.3" to select TLS version 1.3
 	MinVersion string
+
+	// CipherSuites is a list of TLS cipher suites that are allowed.
+	// E.g.:
+	// - TLS_RSA_WITH_RC4_128_SHA
+	// - TLS_RSA_WITH_AES_256_GCM_SHA384
+	// If not specified, the default Go safe cipher list is used.
+	// List of valid cipher suites can be found in the [crypto/tls documentation](https://pkg.go.dev/crypto/tls#pkg-constants).
+	CipherSuites []string
 }
diff --git a/pkg/apis/options/util/util.go b/pkg/apis/options/util/util.go
index 99988bdc..03f0a134 100644
--- a/pkg/apis/options/util/util.go
+++ b/pkg/apis/options/util/util.go
@@ -2,7 +2,6 @@ package util
 
 import (
 	"errors"
-	"io/ioutil"
 	"os"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -16,7 +15,7 @@ func GetSecretValue(source *options.SecretSource) ([]byte, error) {
 	case len(source.Value) == 0 && source.FromEnv != "" && source.FromFile == "":
 		return []byte(os.Getenv(source.FromEnv)), nil
 	case len(source.Value) == 0 && source.FromEnv == "" && source.FromFile != "":
-		return ioutil.ReadFile(source.FromFile)
+		return os.ReadFile(source.FromFile)
 	default:
 		return nil, errors.New("secret source is invalid: exactly one entry required, specify either value, fromEnv or fromFile")
 	}
diff --git a/pkg/apis/sessions/session_state.go b/pkg/apis/sessions/session_state.go
index 08538dae..a69d03df 100644
--- a/pkg/apis/sessions/session_state.go
+++ b/pkg/apis/sessions/session_state.go
@@ -5,7 +5,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"time"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/clock"
@@ -225,7 +224,7 @@ func lz4Compress(payload []byte) ([]byte, error) {
 		return nil, fmt.Errorf("error closing lz4 writer: %w", err)
 	}
 
-	compressed, err := ioutil.ReadAll(buf)
+	compressed, err := io.ReadAll(buf)
 	if err != nil {
 		return nil, fmt.Errorf("error reading lz4 buffer: %w", err)
 	}
@@ -244,7 +243,7 @@ func lz4Decompress(compressed []byte) ([]byte, error) {
 		return nil, fmt.Errorf("error copying lz4 stream to buffer: %w", err)
 	}
 
-	payload, err := ioutil.ReadAll(buf)
+	payload, err := io.ReadAll(buf)
 	if err != nil {
 		return nil, fmt.Errorf("error reading lz4 buffer: %w", err)
 	}
diff --git a/pkg/authentication/basic/htpasswd.go b/pkg/authentication/basic/htpasswd.go
index 47a1dd3f..8a4ac210 100644
--- a/pkg/authentication/basic/htpasswd.go
+++ b/pkg/authentication/basic/htpasswd.go
@@ -8,8 +8,10 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"sync"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/watcher"
 	"golang.org/x/crypto/bcrypt"
 )
 
@@ -17,6 +19,7 @@ import (
 // Passwords must be generated with -B for bcrypt or -s for SHA1.
 type htpasswdMap struct {
 	users map[string]interface{}
+	rwm   sync.RWMutex
 }
 
 // bcryptPass is used to identify bcrypt passwords in the
@@ -30,10 +33,30 @@ type sha1Pass string
 // NewHTPasswdValidator constructs an httpasswd based validator from the file
 // at the path given.
 func NewHTPasswdValidator(path string) (Validator, error) {
+	h := &htpasswdMap{users: make(map[string]interface{})}
+
+	if err := h.loadHTPasswdFile(path); err != nil {
+		return nil, fmt.Errorf("could not load htpasswd file: %v", err)
+	}
+
+	if err := watcher.WatchFileForUpdates(path, nil, func() {
+		err := h.loadHTPasswdFile(path)
+		if err != nil {
+			logger.Errorf("%v: no changes were made to the current htpasswd map", err)
+		}
+	}); err != nil {
+		return nil, fmt.Errorf("could not watch htpasswd file: %v", err)
+	}
+
+	return h, nil
+}
+
+// loadHTPasswdFile loads htpasswd entries from an io.Reader (an opened file) into a htpasswdMap.
+func (h *htpasswdMap) loadHTPasswdFile(filename string) error {
 	// We allow HTPasswd location via config options
-	r, err := os.Open(path) // #nosec G304
+	r, err := os.Open(filename) // #nosec G304
 	if err != nil {
-		return nil, fmt.Errorf("could not open htpasswd file: %v", err)
+		return fmt.Errorf("could not open htpasswd file: %v", err)
 	}
 	defer func(c io.Closer) {
 		cerr := c.Close()
@@ -41,48 +64,81 @@ func NewHTPasswdValidator(path string) (Validator, error) {
 			logger.Fatalf("error closing the htpasswd file: %v", cerr)
 		}
 	}(r)
-	return newHtpasswd(r)
-}
 
-// newHtpasswd consctructs an htpasswd from an io.Reader (an opened file).
-func newHtpasswd(file io.Reader) (*htpasswdMap, error) {
-	csvReader := csv.NewReader(file)
+	csvReader := csv.NewReader(r)
 	csvReader.Comma = ':'
 	csvReader.Comment = '#'
 	csvReader.TrimLeadingSpace = true
 
 	records, err := csvReader.ReadAll()
 	if err != nil {
-		return nil, fmt.Errorf("could not read htpasswd file: %v", err)
+		return fmt.Errorf("could not read htpasswd file: %v", err)
 	}
 
-	return createHtpasswdMap(records)
+	updated, err := createHtpasswdMap(records)
+	if err != nil {
+		return fmt.Errorf("htpasswd entries error: %v", err)
+	}
+
+	h.rwm.Lock()
+	h.users = updated.users
+	h.rwm.Unlock()
+
+	return nil
 }
 
 // createHtasswdMap constructs an htpasswdMap from the given records
 func createHtpasswdMap(records [][]string) (*htpasswdMap, error) {
 	h := &htpasswdMap{users: make(map[string]interface{})}
+	var invalidRecords, invalidEntries []string
 	for _, record := range records {
-		user, realPassword := record[0], record[1]
-		shaPrefix := realPassword[:5]
-		if shaPrefix == "{SHA}" {
-			h.users[user] = sha1Pass(realPassword[5:])
-			continue
+		// If a record is invalid or malformed don't panic with index out of range,
+		// return a formatted error.
+		lr := len(record)
+		switch {
+		case lr == 2:
+			user, realPassword := record[0], record[1]
+			invalidEntries = passShaOrBcrypt(h, user, realPassword)
+		case lr == 1, lr > 2:
+			invalidRecords = append(invalidRecords, record[0])
 		}
+	}
 
-		bcryptPrefix := realPassword[:4]
-		if bcryptPrefix == "$2a$" || bcryptPrefix == "$2b$" || bcryptPrefix == "$2x$" || bcryptPrefix == "$2y$" {
-			h.users[user] = bcryptPass(realPassword)
-			continue
-		}
+	if len(invalidRecords) > 0 {
+		return h, fmt.Errorf("invalid htpasswd record(s) %+q", invalidRecords)
+	}
+
+	if len(invalidEntries) > 0 {
+		return h, fmt.Errorf("'%+q' user(s) could not be added: invalid password, must be a SHA or bcrypt entry", invalidEntries)
+	}
 
-		// Password is neither sha1 or bcrypt
-		// TODO(JoelSpeed): In the next breaking release, make this return an error.
-		logger.Errorf("Invalid htpasswd entry for %s. Must be a SHA or bcrypt entry.", user)
+	if len(h.users) == 0 {
+		return nil, fmt.Errorf("could not construct htpasswdMap: htpasswd file doesn't contain a single valid user entry")
 	}
+
 	return h, nil
 }
 
+// passShaOrBcrypt checks if a htpasswd entry is valid and the password is encrypted with SHA or bcrypt.
+// Valid user entries are saved in the htpasswdMap, invalid records are reurned.
+func passShaOrBcrypt(h *htpasswdMap, user, password string) (invalidEntries []string) {
+	passLen := len(password)
+	switch {
+	case passLen > 6 && password[:5] == "{SHA}":
+		h.users[user] = sha1Pass(password[5:])
+	case passLen > 5 &&
+		(password[:4] == "$2b$" ||
+			password[:4] == "$2y$" ||
+			password[:4] == "$2x$" ||
+			password[:4] == "$2a$"):
+		h.users[user] = bcryptPass(password)
+	default:
+		invalidEntries = append(invalidEntries, user)
+	}
+
+	return invalidEntries
+}
+
 // Validate checks a users password against the htpasswd entries
 func (h *htpasswdMap) Validate(user string, password string) bool {
 	realPassword, exists := h.users[user]
diff --git a/pkg/cookies/csrf.go b/pkg/cookies/csrf.go
index d7c0d9a2..48e20923 100644
--- a/pkg/cookies/csrf.go
+++ b/pkg/cookies/csrf.go
@@ -48,6 +48,9 @@ type csrf struct {
 	time       clock.Clock
 }
 
+// csrtStateTrim will indicate the length of the state trimmed for the name of the csrf cookie
+const csrfStateLength int = 9
+
 // NewCSRF creates a CSRF with random nonces
 func NewCSRF(opts *options.Cookie, codeVerifier string) (CSRF, error) {
 	state, err := encryption.Nonce(32)
@@ -70,7 +73,10 @@ func NewCSRF(opts *options.Cookie, codeVerifier string) (CSRF, error) {
 
 // LoadCSRFCookie loads a CSRF object from a request's CSRF cookie
 func LoadCSRFCookie(req *http.Request, opts *options.Cookie) (CSRF, error) {
-	cookie, err := req.Cookie(csrfCookieName(opts))
+
+	cookieName := GenerateCookieName(req, opts)
+
+	cookie, err := req.Cookie(cookieName)
 	if err != nil {
 		return nil, err
 	}
@@ -78,6 +84,18 @@ func LoadCSRFCookie(req *http.Request, opts *options.Cookie) (CSRF, error) {
 	return decodeCSRFCookie(cookie, opts)
 }
 
+// GenerateCookieName in case cookie options state that CSRF cookie has fixed name then set fixed name, otherwise
+// build name based on the state
+func GenerateCookieName(req *http.Request, opts *options.Cookie) string {
+	stateSubstring := ""
+	if opts.CSRFPerRequest {
+		// csrfCookieName will include a substring of the state to enable multiple csrf cookies
+		// in case of parallel requests
+		stateSubstring = ExtractStateSubstring(req)
+	}
+	return csrfCookieName(opts, stateSubstring)
+}
+
 func (c *csrf) GetCodeVerifier() string {
 	return c.CodeVerifier
 }
@@ -120,7 +138,7 @@ func (c *csrf) SetCookie(rw http.ResponseWriter, req *http.Request) (*http.Cooki
 		c.cookieName(),
 		encoded,
 		c.cookieOpts,
-		c.cookieOpts.Expire,
+		c.cookieOpts.CSRFExpire,
 		c.time.Now(),
 	)
 	http.SetCookie(rw, cookie)
@@ -179,14 +197,35 @@ func decodeCSRFCookie(cookie *http.Cookie, opts *options.Cookie) (*csrf, error)
 	return csrf, nil
 }
 
-// cookieName returns the CSRF cookie's name derived from the base
-// session cookie name
+// cookieName returns the CSRF cookie's name
 func (c *csrf) cookieName() string {
-	return csrfCookieName(c.cookieOpts)
+	stateSubstring := ""
+	if c.cookieOpts.CSRFPerRequest {
+		stateSubstring = encryption.HashNonce(c.OAuthState)[0 : csrfStateLength-1]
+	}
+	return csrfCookieName(c.cookieOpts, stateSubstring)
+}
+
+func csrfCookieName(opts *options.Cookie, stateSubstring string) string {
+	if stateSubstring == "" {
+		return fmt.Sprintf("%v_csrf", opts.Name)
+	}
+	return fmt.Sprintf("%v_csrf_%v", opts.Name, stateSubstring)
 }
 
-func csrfCookieName(opts *options.Cookie) string {
-	return fmt.Sprintf("%v_csrf", opts.Name)
+// ExtractStateSubstring extract the initial state characters, to add it to the CSRF cookie name
+func ExtractStateSubstring(req *http.Request) string {
+	lastChar := csrfStateLength - 1
+	stateSubstring := ""
+
+	state := req.URL.Query()["state"]
+	if state[0] != "" {
+		state := state[0]
+		if lastChar <= len(state) {
+			stateSubstring = state[0:lastChar]
+		}
+	}
+	return stateSubstring
 }
 
 func encrypt(data []byte, opts *options.Cookie) ([]byte, error) {
diff --git a/pkg/http/server.go b/pkg/http/server.go
index b44d5b10..1771dc93 100644
--- a/pkg/http/server.go
+++ b/pkg/http/server.go
@@ -81,6 +81,27 @@ func (s *server) setupListener(opts Opts) error {
 	return nil
 }
 
+func parseCipherSuites(names []string) ([]uint16, error) {
+	cipherNameMap := make(map[string]uint16)
+
+	for _, cipherSuite := range tls.CipherSuites() {
+		cipherNameMap[cipherSuite.Name] = cipherSuite.ID
+	}
+	for _, cipherSuite := range tls.InsecureCipherSuites() {
+		cipherNameMap[cipherSuite.Name] = cipherSuite.ID
+	}
+
+	result := make([]uint16, len(names))
+	for i, name := range names {
+		id, present := cipherNameMap[name]
+		if !present {
+			return nil, fmt.Errorf("unknown TLS cipher suite name specified %q", name)
+		}
+		result[i] = id
+	}
+	return result, nil
+}
+
 // setupTLSListener sets the server TLS listener if the HTTPS server is enabled.
 // The HTTPS server can be disabled by setting the SecureBindAddress to "-" or by
 // leaving it empty.
@@ -104,6 +125,14 @@ func (s *server) setupTLSListener(opts Opts) error {
 	}
 	config.Certificates = []tls.Certificate{cert}
 
+	if len(opts.TLS.CipherSuites) > 0 {
+		cipherSuites, err := parseCipherSuites(opts.TLS.CipherSuites)
+		if err != nil {
+			return fmt.Errorf("could not parse cipher suites: %v", err)
+		}
+		config.CipherSuites = cipherSuites
+	}
+
 	if len(opts.TLS.MinVersion) > 0 {
 		switch opts.TLS.MinVersion {
 		case "TLS1.2":
@@ -157,7 +186,7 @@ func (s *server) Start(ctx context.Context) error {
 // When the given context is cancelled the server will be shutdown.
 // If any errors occur, only the first error will be returned.
 func (s *server) startServer(ctx context.Context, listener net.Listener) error {
-	srv := &http.Server{Handler: s.handler}
+	srv := &http.Server{Handler: s.handler, ReadHeaderTimeout: time.Minute}
 	g, groupCtx := errgroup.WithContext(ctx)
 
 	g.Go(func() error {
diff --git a/pkg/providers/util/claim_extractor.go b/pkg/providers/util/claim_extractor.go
index 883cc64f..c2bd3d1b 100644
--- a/pkg/providers/util/claim_extractor.go
+++ b/pkg/providers/util/claim_extractor.go
@@ -98,7 +98,7 @@ func (c *claimExtractor) loadProfileClaims() (*simplejson.Json, error) {
 		WithContext(c.ctx).
 		WithHeaders(c.requestHeaders).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return nil, fmt.Errorf("error making request to profile URL: %v", err)
 	}
diff --git a/pkg/requests/builder.go b/pkg/requests/builder.go
index 95d88101..a9b084dc 100644
--- a/pkg/requests/builder.go
+++ b/pkg/requests/builder.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/http"
 )
 
@@ -107,7 +106,7 @@ func (r *builder) do() Result {
 	}
 
 	defer resp.Body.Close()
-	body, err := ioutil.ReadAll(resp.Body)
+	body, err := io.ReadAll(resp.Body)
 	if err != nil {
 		r.result = &result{err: fmt.Errorf("error reading response body: %v", err)}
 		return r.result
diff --git a/pkg/requests/result.go b/pkg/requests/result.go
index 305fefe2..70a8b334 100644
--- a/pkg/requests/result.go
+++ b/pkg/requests/result.go
@@ -15,7 +15,7 @@ type Result interface {
 	Headers() http.Header
 	Body() []byte
 	UnmarshalInto(interface{}) error
-	UnmarshalJSON() (*simplejson.Json, error)
+	UnmarshalSimpleJSON() (*simplejson.Json, error)
 }
 
 type result struct {
@@ -66,10 +66,10 @@ func (r *result) UnmarshalInto(into interface{}) error {
 	return nil
 }
 
-// UnmarshalJSON performs the request and attempts to unmarshal the response into a
+// UnmarshalSimpleJSON performs the request and attempts to unmarshal the response into a
 // simplejson.Json. The response body is assume to be JSON.
 // The response must have a 200 status otherwise an error will be returned.
-func (r *result) UnmarshalJSON() (*simplejson.Json, error) {
+func (r *result) UnmarshalSimpleJSON() (*simplejson.Json, error) {
 	body, err := r.getBodyForUnmarshal()
 	if err != nil {
 		return nil, err
diff --git a/pkg/sessions/redis/client.go b/pkg/sessions/redis/client.go
index d17aebbe..ac1c540e 100644
--- a/pkg/sessions/redis/client.go
+++ b/pkg/sessions/redis/client.go
@@ -4,7 +4,7 @@ import (
 	"context"
 	"time"
 
-	"github.com/go-redis/redis/v8"
+	"github.com/go-redis/redis/v9"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
 )
 
diff --git a/pkg/sessions/redis/lock.go b/pkg/sessions/redis/lock.go
index 91da0e97..0f7b5e97 100644
--- a/pkg/sessions/redis/lock.go
+++ b/pkg/sessions/redis/lock.go
@@ -7,7 +7,7 @@ import (
 	"time"
 
 	"github.com/bsm/redislock"
-	"github.com/go-redis/redis/v8"
+	"github.com/go-redis/redis/v9"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
 )
 
diff --git a/pkg/sessions/redis/redis_store.go b/pkg/sessions/redis/redis_store.go
index 1243c7c5..fccea249 100644
--- a/pkg/sessions/redis/redis_store.go
+++ b/pkg/sessions/redis/redis_store.go
@@ -5,10 +5,10 @@ import (
 	"crypto/tls"
 	"crypto/x509"
 	"fmt"
-	"io/ioutil"
+	"os"
 	"time"
 
-	"github.com/go-redis/redis/v8"
+	"github.com/go-redis/redis/v9"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
@@ -104,7 +104,7 @@ func buildSentinelClient(opts options.RedisStoreOptions) (Client, error) {
 		SentinelPassword: opts.SentinelPassword,
 		Password:         opts.Password,
 		TLSConfig:        opt.TLSConfig,
-		IdleTimeout:      time.Duration(opts.IdleTimeout) * time.Second,
+		ConnMaxIdleTime:  time.Duration(opts.IdleTimeout) * time.Second,
 	})
 	return newClient(client), nil
 }
@@ -121,10 +121,10 @@ func buildClusterClient(opts options.RedisStoreOptions) (Client, error) {
 	}
 
 	client := redis.NewClusterClient(&redis.ClusterOptions{
-		Addrs:       addrs,
-		Password:    opts.Password,
-		TLSConfig:   opt.TLSConfig,
-		IdleTimeout: time.Duration(opts.IdleTimeout) * time.Second,
+		Addrs:           addrs,
+		Password:        opts.Password,
+		TLSConfig:       opt.TLSConfig,
+		ConnMaxIdleTime: time.Duration(opts.IdleTimeout) * time.Second,
 	})
 	return newClusterClient(client), nil
 }
@@ -145,7 +145,7 @@ func buildStandaloneClient(opts options.RedisStoreOptions) (Client, error) {
 		return nil, err
 	}
 
-	opt.IdleTimeout = time.Duration(opts.IdleTimeout) * time.Second
+	opt.ConnMaxIdleTime = time.Duration(opts.IdleTimeout) * time.Second
 
 	client := redis.NewClient(opt)
 	return newClient(client), nil
@@ -170,7 +170,7 @@ func setupTLSConfig(opts options.RedisStoreOptions, opt *redis.Options) error {
 		if rootCAs == nil {
 			rootCAs = x509.NewCertPool()
 		}
-		certs, err := ioutil.ReadFile(opts.CAPath)
+		certs, err := os.ReadFile(opts.CAPath)
 		if err != nil {
 			return fmt.Errorf("failed to load %q, %v", opts.CAPath, err)
 		}
diff --git a/pkg/util/util.go b/pkg/util/util.go
index d8c1fc06..a4425d9b 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -6,10 +6,10 @@ import (
 	"crypto/x509"
 	"crypto/x509/pkix"
 	"fmt"
-	"io/ioutil"
 	"math/big"
 	"net"
 	"net/url"
+	"os"
 	"strings"
 	"time"
 )
@@ -21,7 +21,7 @@ func GetCertPool(paths []string) (*x509.CertPool, error) {
 	pool := x509.NewCertPool()
 	for _, path := range paths {
 		// Cert paths are a configurable option
-		data, err := ioutil.ReadFile(path) // #nosec G304
+		data, err := os.ReadFile(path) // #nosec G304
 		if err != nil {
 			return nil, fmt.Errorf("certificate authority file (%s) could not be read - %s", path, err)
 		}
@@ -149,3 +149,16 @@ func isHostnameAllowed(hostname, allowedHost string) bool {
 
 	return false
 }
+
+// RemoveDuplicateStr removes duplicates from a slice of strings.
+func RemoveDuplicateStr(strSlice []string) []string {
+	allKeys := make(map[string]struct{})
+	var list []string
+	for _, item := range strSlice {
+		if _, ok := allKeys[item]; !ok {
+			allKeys[item] = struct{}{}
+			list = append(list, item)
+		}
+	}
+	return list
+}
diff --git a/pkg/validation/allowlist.go b/pkg/validation/allowlist.go
index 56a3fd4c..7a36027a 100644
--- a/pkg/validation/allowlist.go
+++ b/pkg/validation/allowlist.go
@@ -13,8 +13,8 @@ import (
 func validateAllowlists(o *options.Options) []string {
 	msgs := []string{}
 
-	msgs = append(msgs, validateRoutes(o)...)
-	msgs = append(msgs, validateRegexes(o)...)
+	msgs = append(msgs, validateAuthRoutes(o)...)
+	msgs = append(msgs, validateAuthRegexes(o)...)
 	msgs = append(msgs, validateTrustedIPs(o)...)
 
 	if len(o.TrustedIPs) > 0 && o.ReverseProxy {
@@ -27,8 +27,8 @@ func validateAllowlists(o *options.Options) []string {
 	return msgs
 }
 
-// validateRoutes validates method=path routes passed with options.SkipAuthRoutes
-func validateRoutes(o *options.Options) []string {
+// validateAuthRoutes validates method=path routes passed with options.SkipAuthRoutes
+func validateAuthRoutes(o *options.Options) []string {
 	msgs := []string{}
 	for _, route := range o.SkipAuthRoutes {
 		var regex string
@@ -47,15 +47,8 @@ func validateRoutes(o *options.Options) []string {
 }
 
 // validateRegex validates regex paths passed with options.SkipAuthRegex
-func validateRegexes(o *options.Options) []string {
-	msgs := []string{}
-	for _, regex := range o.SkipAuthRegex {
-		_, err := regexp.Compile(regex)
-		if err != nil {
-			msgs = append(msgs, fmt.Sprintf("error compiling regex /%s/: %v", regex, err))
-		}
-	}
-	return msgs
+func validateAuthRegexes(o *options.Options) []string {
+	return validateRegexes(o.SkipAuthRegex)
 }
 
 // validateTrustedIPs validates IP/CIDRs for IP based allowlists
@@ -68,3 +61,20 @@ func validateTrustedIPs(o *options.Options) []string {
 	}
 	return msgs
 }
+
+// validateAPIRoutes validates regex paths passed with options.ApiRoutes
+func validateAPIRoutes(o *options.Options) []string {
+	return validateRegexes(o.APIRoutes)
+}
+
+// validateRegexes validates all regexes and returns a list of messages in case of error
+func validateRegexes(regexes []string) []string {
+	msgs := []string{}
+	for _, regex := range regexes {
+		_, err := regexp.Compile(regex)
+		if err != nil {
+			msgs = append(msgs, fmt.Sprintf("error compiling regex /%s/: %v", regex, err))
+		}
+	}
+	return msgs
+}
diff --git a/pkg/validation/options.go b/pkg/validation/options.go
index cd8f24f9..a3ce0518 100644
--- a/pkg/validation/options.go
+++ b/pkg/validation/options.go
@@ -25,6 +25,7 @@ func Validate(o *options.Options) error {
 	msgs = append(msgs, prefixValues("injectRequestHeaders: ", validateHeaders(o.InjectRequestHeaders)...)...)
 	msgs = append(msgs, prefixValues("injectResponseHeaders: ", validateHeaders(o.InjectResponseHeaders)...)...)
 	msgs = append(msgs, validateProviders(o)...)
+	msgs = append(msgs, validateAPIRoutes(o)...)
 	msgs = configureLogger(o.Logging, msgs)
 	msgs = parseSignatureKey(o, msgs)
 
diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go
index 587b9289..cb84ad90 100644
--- a/pkg/validation/providers.go
+++ b/pkg/validation/providers.go
@@ -2,7 +2,6 @@ package validation
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -53,7 +52,7 @@ func validateProvider(provider options.Provider, providerIDs map[string]struct{}
 			msgs = append(msgs, "missing setting: client-secret or client-secret-file")
 		}
 		if provider.ClientSecret == "" && provider.ClientSecretFile != "" {
-			_, err := ioutil.ReadFile(provider.ClientSecretFile)
+			_, err := os.ReadFile(provider.ClientSecretFile)
 			if err != nil {
 				msgs = append(msgs, "could not read client secret file: "+provider.ClientSecretFile)
 			}
diff --git a/pkg/watcher/watcher.go b/pkg/watcher/watcher.go
new file mode 100644
index 00000000..b609e969
--- /dev/null
+++ b/pkg/watcher/watcher.go
@@ -0,0 +1,81 @@
+package watcher
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/fsnotify/fsnotify"
+
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
+)
+
+// WatchFileForUpdates performs an action every time a file on disk is updated
+func WatchFileForUpdates(filename string, done <-chan bool, action func()) error {
+	filename = filepath.Clean(filename)
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		return fmt.Errorf("failed to create watcher for '%s': %s", filename, err)
+	}
+
+	go func() {
+		defer watcher.Close()
+
+		for {
+			select {
+			case <-done:
+				logger.Printf("shutting down watcher for: %s", filename)
+				return
+			case event := <-watcher.Events:
+				filterEvent(watcher, event, filename, action)
+			case err = <-watcher.Errors:
+				logger.Errorf("error watching '%s': %s", filename, err)
+			}
+		}
+	}()
+	if err := watcher.Add(filename); err != nil {
+		return fmt.Errorf("failed to add '%s' to watcher: %v", filename, err)
+	}
+	logger.Printf("watching '%s' for updates", filename)
+
+	return nil
+}
+
+// Filter file operations based on the events sent by the watcher.
+// Execute the action() function when the following conditions are met:
+//   - the real path of the file was changed (Kubernetes ConfigMap/Secret)
+//   - the file is modified or created
+func filterEvent(watcher *fsnotify.Watcher, event fsnotify.Event, filename string, action func()) {
+	switch filepath.Clean(event.Name) == filename {
+	// In Kubernetes the file path is a symlink, so we should take action
+	// when the ConfigMap/Secret is replaced.
+	case event.Op&fsnotify.Remove != 0:
+		logger.Printf("watching interrupted on event: %s", event)
+		WaitForReplacement(filename, event.Op, watcher)
+		action()
+	case event.Op&(fsnotify.Create|fsnotify.Write) != 0:
+		logger.Printf("reloading after event: %s", event)
+		action()
+	}
+}
+
+// WaitForReplacement waits for a file to exist on disk and then starts a watch
+// for the file
+func WaitForReplacement(filename string, op fsnotify.Op, watcher *fsnotify.Watcher) {
+	const sleepInterval = 50 * time.Millisecond
+
+	// Avoid a race when fsnofity.Remove is preceded by fsnotify.Chmod.
+	if op&fsnotify.Chmod != 0 {
+		time.Sleep(sleepInterval)
+	}
+	for {
+		if _, err := os.Stat(filename); err == nil {
+			if err := watcher.Add(filename); err == nil {
+				logger.Printf("watching resumed for '%s'", filename)
+				return
+			}
+		}
+		time.Sleep(sleepInterval)
+	}
+}
diff --git a/providers/azure.go b/providers/azure.go
index 48d55ec2..a3e80f3f 100644
--- a/providers/azure.go
+++ b/providers/azure.go
@@ -3,63 +3,60 @@ package providers
 import (
 	"bytes"
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
+	"strings"
 	"time"
 
+	"golang.org/x/exp/slices"
+
 	"github.com/bitly/go-simplejson"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
 )
 
 // AzureProvider represents an Azure based Identity Provider
 type AzureProvider struct {
 	*ProviderData
-	Tenant string
+	Tenant          string
+	GraphGroupField string
+	isV2Endpoint    bool
 }
 
 var _ Provider = (*AzureProvider)(nil)
 
 const (
-	azureProviderName = "Azure"
-	azureDefaultScope = "openid"
+	azureProviderName           = "Azure"
+	azureDefaultScope           = "openid"
+	azureDefaultGraphGroupField = "id"
+	azureV2Scope                = "https://graph.microsoft.com/.default"
 )
 
 var (
-	// Default Login URL for Azure.
-	// Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
+	// Default Login URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
 	azureDefaultLoginURL = &url.URL{
 		Scheme: "https",
 		Host:   "login.microsoftonline.com",
 		Path:   "/common/oauth2/authorize",
 	}
 
-	// Default Redeem URL for Azure.
-	// Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
+	// Default Redeem URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
 	azureDefaultRedeemURL = &url.URL{
 		Scheme: "https",
 		Host:   "login.microsoftonline.com",
 		Path:   "/common/oauth2/token",
 	}
 
-	// Default Profile URL for Azure.
-	// Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
+	// Default Profile URL for Azure. Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
 	azureDefaultProfileURL = &url.URL{
 		Scheme: "https",
 		Host:   "graph.microsoft.com",
 		Path:   "/v1.0/me",
 	}
-
-	// Default ProtectedResource URL for Azure.
-	// Pre-parsed URL of https://graph.microsoft.com.
-	azureDefaultProtectResourceURL = &url.URL{
-		Scheme: "https",
-		Host:   "graph.microsoft.com",
-	}
 )
 
 // NewAzureProvider initiates a new AzureProvider
@@ -73,9 +70,6 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
 		scope:       azureDefaultScope,
 	})
 
-	if p.ProtectedResource == nil || p.ProtectedResource.String() == "" {
-		p.ProtectedResource = azureDefaultProtectResourceURL
-	}
 	if p.ValidateURL == nil || p.ValidateURL.String() == "" {
 		p.ValidateURL = p.ProfileURL
 	}
@@ -88,9 +82,35 @@ func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider
 		overrideTenantURL(p.RedeemURL, azureDefaultRedeemURL, tenant, "token")
 	}
 
+	graphGroupField := azureDefaultGraphGroupField
+	if opts.GraphGroupField != "" {
+		graphGroupField = opts.GraphGroupField
+	}
+
+	isV2Endpoint := false
+	if strings.Contains(p.LoginURL.String(), "v2.0") {
+		isV2Endpoint = true
+
+		if strings.Contains(p.Scope, " groups") {
+			logger.Print("WARNING: `groups` scope is not an accepted scope when using Azure OAuth V2 endpoint. Removing it from the scope list")
+			p.Scope = strings.ReplaceAll(p.Scope, " groups", "")
+		}
+
+		if !strings.Contains(p.Scope, " "+azureV2Scope) {
+			// In order to be able to query MS Graph we must pass the ms graph default endpoint
+			p.Scope += " " + azureV2Scope
+		}
+
+		if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
+			logger.Print("WARNING: `--resource` option has no effect when using the Azure OAuth V2 endpoint.")
+		}
+	}
+
 	return &AzureProvider{
-		ProviderData: p,
-		Tenant:       tenant,
+		ProviderData:    p,
+		Tenant:          tenant,
+		GraphGroupField: graphGroupField,
+		isV2Endpoint:    isV2Endpoint,
 	}
 }
 
@@ -103,8 +123,26 @@ func overrideTenantURL(current, defaultURL *url.URL, tenant, path string) {
 	}
 }
 
+func getMicrosoftGraphGroupsURL(graphGroupField string) *url.URL {
+
+	selectStatement := "$select=displayName,id"
+	if !slices.Contains([]string{"displayName", "id"}, graphGroupField) {
+		selectStatement += "," + graphGroupField
+	}
+
+	// Select only security groups. Due to the filter option, count param is mandatory even if unused otherwise
+	return &url.URL{
+		Scheme:   "https",
+		Host:     "graph.microsoft.com",
+		Path:     "/v1.0/me/transitiveMemberOf",
+		RawQuery: "$count=true&$filter=securityEnabled+eq+true&" + selectStatement,
+	}
+}
+
 func (p *AzureProvider) GetLoginURL(redirectURI, state, _ string, extraParams url.Values) string {
-	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
+	// In azure oauth v2 there is no resource param so add it only if V1 endpoint
+	// https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
+	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
 		extraParams.Add("resource", p.ProtectedResource.String())
 	}
 	a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
@@ -145,45 +183,39 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code, codeVerif
 	session.CreatedAtNow()
 	session.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
 
-	email, err := p.verifyTokenAndExtractEmail(ctx, session.IDToken, session.AccessToken)
+	err = p.extractClaimsIntoSession(ctx, session)
 
-	// https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
-	// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
-	// due to above issues, id_token may not be signed by AAD
-	// in that case, we will fallback to access token
-	if err == nil && email != "" {
-		session.Email = email
-	} else {
-		logger.Printf("unable to get email claim from id_token: %v", err)
-	}
-
-	if session.Email == "" {
-		email, err = p.verifyTokenAndExtractEmail(ctx, session.AccessToken, session.AccessToken)
-		if err == nil && email != "" {
-			session.Email = email
-		} else {
-			logger.Printf("unable to get email claim from access token: %v", err)
-		}
+	if err != nil {
+		return nil, fmt.Errorf("unable to get email and/or groups claims from token: %v", err)
 	}
 
 	return session, nil
 }
 
-// EnrichSession finds the email to enrich the session state
-func (p *AzureProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
-	if s.Email != "" {
-		return nil
-	}
+// EnrichSession enriches the session state with userID, mail and groups
+func (p *AzureProvider) EnrichSession(ctx context.Context, session *sessions.SessionState) error {
+	err := p.extractClaimsIntoSession(ctx, session)
 
-	email, err := p.getEmailFromProfileAPI(ctx, s.AccessToken)
 	if err != nil {
-		return fmt.Errorf("unable to get email address: %v", err)
+		logger.Printf("unable to get email and/or groups claims from token: %v", err)
 	}
-	if email == "" {
-		return errors.New("unable to get email address")
+
+	if session.Email == "" {
+		email, err := p.getEmailFromProfileAPI(ctx, session.AccessToken)
+		if err != nil {
+			return fmt.Errorf("unable to get email address from profile URL: %v", err)
+		}
+		session.Email = email
 	}
-	s.Email = email
 
+	// If using the v2.0 oidc endpoint we're also querying Microsoft Graph
+	if p.isV2Endpoint {
+		groups, err := p.getGroupsFromProfileAPI(ctx, session)
+		if err != nil {
+			return fmt.Errorf("unable to get groups from Microsoft Graph: %v", err)
+		}
+		session.Groups = util.RemoveDuplicateStr(append(session.Groups, groups...))
+	}
 	return nil
 }
 
@@ -205,33 +237,66 @@ func (p *AzureProvider) prepareRedeem(redirectURL, code, codeVerifier string) (u
 	if codeVerifier != "" {
 		params.Add("code_verifier", codeVerifier)
 	}
-	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
+
+	// In azure oauth v2 there is no resource param so add it only if V1 endpoint
+	// https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
+	if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
 		params.Add("resource", p.ProtectedResource.String())
 	}
+
 	return params, nil
 }
 
-// verifyTokenAndExtractEmail tries to extract email claim from either id_token or access token
+// extractClaimsIntoSession tries to extract email and groups claims from either id_token or access token
 // when oidc verifier is configured
-func (p *AzureProvider) verifyTokenAndExtractEmail(ctx context.Context, rawIDToken string, accessToken string) (string, error) {
-	email := ""
-
-	if rawIDToken != "" && p.Verifier != nil {
-		_, err := p.Verifier.Verify(ctx, rawIDToken)
-		// due to issues mentioned above, id_token may not be signed by AAD
-		if err == nil {
-			s, err := p.buildSessionFromClaims(rawIDToken, accessToken)
-			if err == nil {
-				email = s.Email
-			} else {
-				logger.Printf("unable to get claims from token: %v", err)
+func (p *AzureProvider) extractClaimsIntoSession(ctx context.Context, session *sessions.SessionState) error {
+
+	var s *sessions.SessionState
+
+	// First let's verify session token
+	if err := p.verifySessionToken(ctx, session); err != nil {
+		return fmt.Errorf("unable to verify token: %v", err)
+	}
+
+	// https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
+	// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
+	// due to above issues, id_token may not be signed by AAD
+	// in that case, we will fallback to access token
+	var err error
+	s, err = p.buildSessionFromClaims(session.IDToken, session.AccessToken)
+	if err != nil || s.Email == "" {
+		s, err = p.buildSessionFromClaims(session.AccessToken, session.AccessToken)
+	}
+	if err != nil {
+		return fmt.Errorf("unable to get claims from token: %v", err)
+	}
+
+	session.Email = s.Email
+	if s.Groups != nil {
+		session.Groups = s.Groups
+	}
+
+	return nil
+}
+
+// verifySessionToken tries to validate id_token if present or access token when oidc verifier is configured
+func (p *AzureProvider) verifySessionToken(ctx context.Context, session *sessions.SessionState) error {
+	// Without a verifier there's no way to verify
+	if p.Verifier == nil {
+		return nil
+	}
+
+	if session.IDToken != "" {
+		if _, err := p.Verifier.Verify(ctx, session.IDToken); err != nil {
+			logger.Printf("unable to verify ID token, fallback to access token: %v", err)
+			if _, err = p.Verifier.Verify(ctx, session.AccessToken); err != nil {
+				return fmt.Errorf("unable to verify access token: %v", err)
 			}
-		} else {
-			logger.Printf("unable to verify token: %v", err)
 		}
+	} else if _, err := p.Verifier.Verify(ctx, session.AccessToken); err != nil {
+		return fmt.Errorf("unable to verify access token: %v", err)
 	}
-
-	return email, nil
+	return nil
 }
 
 // RefreshSession uses the RefreshToken to fetch new Access and ID Tokens
@@ -285,25 +350,10 @@ func (p *AzureProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sess
 	s.CreatedAtNow()
 	s.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
 
-	email, err := p.verifyTokenAndExtractEmail(ctx, s.IDToken, s.AccessToken)
+	err = p.extractClaimsIntoSession(ctx, s)
 
-	// https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
-	// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
-	// due to above issues, id_token may not be signed by AAD
-	// in that case, we will fallback to access token
-	if err == nil && email != "" {
-		s.Email = email
-	} else {
-		logger.Printf("unable to get email claim from id_token: %v", err)
-	}
-
-	if s.Email == "" {
-		email, err = p.verifyTokenAndExtractEmail(ctx, s.AccessToken, s.AccessToken)
-		if err == nil && email != "" {
-			s.Email = email
-		} else {
-			logger.Printf("unable to get email claim from access token: %v", err)
-		}
+	if err != nil {
+		logger.Printf("unable to get email and/or groups claims from token: %v", err)
 	}
 
 	return nil
@@ -313,46 +363,93 @@ func makeAzureHeader(accessToken string) http.Header {
 	return makeAuthorizationHeader(tokenTypeBearer, accessToken, nil)
 }
 
-func getEmailFromJSON(json *simplejson.Json) (string, error) {
-	var email string
-	var err error
+func (p *AzureProvider) getGroupsFromProfileAPI(ctx context.Context, s *sessions.SessionState) ([]string, error) {
+	if s.AccessToken == "" {
+		return nil, fmt.Errorf("missing access token")
+	}
 
-	email, err = json.Get("mail").String()
+	groupsURL := getMicrosoftGraphGroupsURL(p.GraphGroupField).String()
 
-	if err != nil || email == "" {
-		otherMails, otherMailsErr := json.Get("otherMails").Array()
-		if len(otherMails) > 0 {
-			email = otherMails[0].(string)
-		}
-		err = otherMailsErr
-	}
+	// Need and extra header while talking with MS Graph. For more context see
+	// https://docs.microsoft.com/en-us/graph/api/group-list-transitivememberof?view=graph-rest-1.0&tabs=http#request-headers
+	extraHeader := makeAzureHeader(s.AccessToken)
+	extraHeader.Add("ConsistencyLevel", "eventual")
 
-	if err != nil || email == "" {
-		email, err = json.Get("userPrincipalName").String()
+	var groups []string
+
+	for groupsURL != "" {
+		jsonRequest, err := requests.New(groupsURL).
+			WithContext(ctx).
+			WithHeaders(extraHeader).
+			Do().
+			UnmarshalSimpleJSON()
 		if err != nil {
-			logger.Errorf("unable to find userPrincipalName: %s", err)
-			return "", err
+			return nil, fmt.Errorf("unable to unmarshal Microsoft Graph response: %v", err)
+
 		}
+		groupsURL, err = jsonRequest.Get("@odata.nextLink").String()
+		if err != nil {
+			groupsURL = ""
+		}
+		groupsPage := getGroupsFromJSON(jsonRequest, p.GraphGroupField)
+		groups = append(groups, groupsPage...)
+	}
+
+	return groups, nil
+}
+
+func getGroupsFromJSON(json *simplejson.Json, graphGroupField string) []string {
+	groups := []string{}
+
+	for i := range json.Get("value").MustArray() {
+		value := json.Get("value").GetIndex(i).Get(graphGroupField).MustString()
+		groups = append(groups, value)
 	}
 
-	return email, err
+	return groups
 }
 
 func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken string) (string, error) {
 	if accessToken == "" {
-		return "", errors.New("missing access token")
+		return "", fmt.Errorf("missing access token")
 	}
 
 	json, err := requests.New(p.ProfileURL.String()).
 		WithContext(ctx).
 		WithHeaders(makeAzureHeader(accessToken)).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return "", err
 	}
 
-	return getEmailFromJSON(json)
+	email, err := getEmailFromJSON(json)
+	if email == "" {
+		return "", fmt.Errorf("empty email address: %v", err)
+	}
+	return email, nil
+}
+
+func getEmailFromJSON(json *simplejson.Json) (string, error) {
+	email, err := json.Get("mail").String()
+
+	if err != nil || email == "" {
+		otherMails, otherMailsErr := json.Get("otherMails").Array()
+		if len(otherMails) > 0 {
+			email = otherMails[0].(string)
+		}
+		err = otherMailsErr
+	}
+
+	if err != nil || email == "" {
+		email, err = json.Get("userPrincipalName").String()
+		if err != nil {
+			logger.Errorf("unable to find userPrincipalName: %s", err)
+			return "", err
+		}
+	}
+
+	return email, nil
 }
 
 // ValidateSession validates the AccessToken
diff --git a/providers/digitalocean.go b/providers/digitalocean.go
index acbd6f70..bc1a6b26 100644
--- a/providers/digitalocean.go
+++ b/providers/digitalocean.go
@@ -72,7 +72,7 @@ func (p *DigitalOceanProvider) GetEmailAddress(ctx context.Context, s *sessions.
 		WithContext(ctx).
 		WithHeaders(makeOIDCHeader(s.AccessToken)).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return "", err
 	}
diff --git a/providers/google.go b/providers/google.go
index 40501aa6..62399f09 100644
--- a/providers/google.go
+++ b/providers/google.go
@@ -8,7 +8,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/url"
 	"os"
 	"strings"
@@ -231,7 +230,7 @@ func (p *GoogleProvider) setGroupRestriction(groups []string, adminEmail string,
 }
 
 func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Service {
-	data, err := ioutil.ReadAll(credentialsReader)
+	data, err := io.ReadAll(credentialsReader)
 	if err != nil {
 		logger.Fatal("can't read Google credentials file:", err)
 	}
diff --git a/providers/keycloak.go b/providers/keycloak.go
index 4a8af231..9b7510be 100644
--- a/providers/keycloak.go
+++ b/providers/keycloak.go
@@ -77,7 +77,7 @@ func (p *KeycloakProvider) EnrichSession(ctx context.Context, s *sessions.Sessio
 		WithContext(ctx).
 		SetHeader("Authorization", "Bearer "+s.AccessToken).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		logger.Errorf("failed making request %v", err)
 		return err
@@ -98,6 +98,20 @@ func (p *KeycloakProvider) EnrichSession(ctx context.Context, s *sessions.Sessio
 	}
 	s.Email = email
 
+	preferredUsername, err := json.Get("preferred_username").String()
+	if err == nil {
+		s.PreferredUsername = preferredUsername
+	}
+
+	user, err := json.Get("user").String()
+	if err == nil {
+		s.User = user
+	}
+
+	if s.User == "" && s.PreferredUsername != "" {
+		s.User = s.PreferredUsername
+	}
+
 	return nil
 }
 
diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go
index 28abae3a..6603f0ea 100644
--- a/providers/keycloak_oidc.go
+++ b/providers/keycloak_oidc.go
@@ -124,20 +124,21 @@ func (p *KeycloakOIDCProvider) getAccessClaims(ctx context.Context, s *sessions.
 // the format `client:role`.
 //
 // ResourceAccess format:
-// "resource_access": {
-//   "clientA": {
-//     "roles": [
-//       "roleA"
-//     ]
-//   },
-//   "clientB": {
-//     "roles": [
-//       "roleA",
-//       "roleB",
-//       "roleC"
-//     ]
-//   }
-// }
+//
+//	"resource_access": {
+//	  "clientA": {
+//	    "roles": [
+//	      "roleA"
+//	    ]
+//	  },
+//	  "clientB": {
+//	    "roles": [
+//	      "roleA",
+//	      "roleB",
+//	      "roleC"
+//	    ]
+//	  }
+//	}
 func getClientRoles(claims *accessClaims) []string {
 	var clientRoles []string
 	for clientName, access := range claims.ResourceAccess {
diff --git a/providers/linkedin.go b/providers/linkedin.go
index 3904d840..62b971ca 100644
--- a/providers/linkedin.go
+++ b/providers/linkedin.go
@@ -90,7 +90,7 @@ func (p *LinkedInProvider) GetEmailAddress(ctx context.Context, s *sessions.Sess
 		WithContext(ctx).
 		WithHeaders(makeLinkedInHeader(s.AccessToken)).
 		Do().
-		UnmarshalJSON()
+		UnmarshalSimpleJSON()
 	if err != nil {
 		return "", err
 	}
diff --git a/providers/logingov.go b/providers/logingov.go
index e321b9e0..2660b015 100644
--- a/providers/logingov.go
+++ b/providers/logingov.go
@@ -7,9 +7,9 @@ import (
 	"crypto/rsa"
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"math/big"
 	"net/url"
+	"os"
 	"time"
 
 	"github.com/golang-jwt/jwt"
@@ -123,7 +123,7 @@ func (p *LoginGovProvider) configure(opts options.LoginGovOptions) error {
 		p.JWTKey = signKey
 	case opts.JWTKeyFile != "":
 		// The JWT key is in the filesystem
-		keyData, err := ioutil.ReadFile(opts.JWTKeyFile)
+		keyData, err := os.ReadFile(opts.JWTKeyFile)
 		if err != nil {
 			return fmt.Errorf("could not read key file: %v", opts.JWTKeyFile)
 		}
diff --git a/providers/nextcloud.go b/providers/nextcloud.go
index 882fc63e..c1538b36 100644
--- a/providers/nextcloud.go
+++ b/providers/nextcloud.go
@@ -1,6 +1,14 @@
 package providers
 
-import "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
+import (
+	"context"
+	"fmt"
+
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
+)
 
 // NextcloudProvider represents an Nextcloud based Identity Provider
 type NextcloudProvider struct {
@@ -22,3 +30,51 @@ func NewNextcloudProvider(p *ProviderData) *NextcloudProvider {
 	}
 	return &NextcloudProvider{ProviderData: p}
 }
+
+// EnrichSession uses the Nextcloud userinfo endpoint to populate
+// the session's email, user, and groups.
+func (p *NextcloudProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
+	// Fallback to ValidateURL if ProfileURL not set for legacy compatibility
+	profileURL := p.ValidateURL.String()
+	if p.ProfileURL.String() != "" {
+		profileURL = p.ProfileURL.String()
+	}
+
+	json, err := requests.New(profileURL).
+		WithContext(ctx).
+		SetHeader("Authorization", "Bearer "+s.AccessToken).
+		Do().
+		UnmarshalSimpleJSON()
+	if err != nil {
+		logger.Errorf("failed making request %v", err)
+		return err
+	}
+
+	groups, err := json.GetPath("ocs", "data", "groups").StringArray()
+	if err == nil {
+		for _, group := range groups {
+			if group != "" {
+				s.Groups = append(s.Groups, group)
+			}
+		}
+	}
+
+	user, err := json.GetPath("ocs", "data", "id").String()
+	if err != nil {
+		return fmt.Errorf("unable to extract id from userinfo endpoint: %v", err)
+	}
+	s.User = user
+
+	email, err := json.GetPath("ocs", "data", "email").String()
+	if err != nil {
+		return fmt.Errorf("unable to extract email from userinfo endpoint: %v", err)
+	}
+	s.Email = email
+
+	return nil
+}
+
+// ValidateSession validates the AccessToken
+func (p *NextcloudProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
+	return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken))
+}
diff --git a/providers/oidc.go b/providers/oidc.go
index d3902afb..aadaf7c5 100644
--- a/providers/oidc.go
+++ b/providers/oidc.go
@@ -22,7 +22,14 @@ type OIDCProvider struct {
 
 // NewOIDCProvider initiates a new OIDCProvider
 func NewOIDCProvider(p *ProviderData, opts options.OIDCOptions) *OIDCProvider {
-	p.ProviderName = "OpenID Connect"
+	p.setProviderDefaults(providerDefaults{
+		name:        "OpenID Connect",
+		loginURL:    nil,
+		redeemURL:   nil,
+		profileURL:  nil,
+		validateURL: nil,
+		scope:       "",
+	})
 	p.getAuthorizationHeaderFunc = makeOIDCHeader
 
 	return &OIDCProvider{
diff --git a/providers/provider_data.go b/providers/provider_data.go
index 647ea172..0e3f090f 100644
--- a/providers/provider_data.go
+++ b/providers/provider_data.go
@@ -4,9 +4,9 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"net/http"
 	"net/url"
+	"os"
 	"regexp"
 	"strings"
 
@@ -67,7 +67,7 @@ func (p *ProviderData) GetClientSecret() (clientSecret string, err error) {
 	}
 
 	// Getting ClientSecret can fail in runtime so we need to report it without returning the file name to the user
-	fileClientSecret, err := ioutil.ReadFile(p.ClientSecretFile)
+	fileClientSecret, err := os.ReadFile(p.ClientSecretFile)
 	if err != nil {
 		logger.Errorf("error reading client secret file %s: %s", p.ClientSecretFile, err)
 		return "", errors.New("could not read client secret file")
@@ -267,14 +267,16 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (*
 	// considered unverified.
 	verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail
 
-	var verified bool
-	exists, err := extractor.GetClaimInto("email_verified", &verified)
-	if err != nil {
-		return nil, err
-	}
+	if verifyEmail {
+		var verified bool
+		exists, err := extractor.GetClaimInto("email_verified", &verified)
+		if err != nil {
+			return nil, err
+		}
 
-	if verifyEmail && exists && !verified {
-		return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
+		if exists && !verified {
+			return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
+		}
 	}
 
 	return ss, nil
diff --git a/validator.go b/validator.go
index 7f5349a8..587ef857 100644
--- a/validator.go
+++ b/validator.go
@@ -9,6 +9,7 @@ import (
 	"unsafe"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/watcher"
 )
 
 // UserMap holds information from the authenticated emails file
@@ -26,7 +27,7 @@ func NewUserMap(usersFile string, done <-chan bool, onUpdate func()) *UserMap {
 	atomic.StorePointer(&um.m, unsafe.Pointer(&m)) // #nosec G103
 	if usersFile != "" {
 		logger.Printf("using authenticated emails file %s", usersFile)
-		WatchForUpdates(usersFile, done, func() {
+		watcher.WatchFileForUpdates(usersFile, done, func() {
 			um.LoadAuthenticatedEmailsFile()
 			onUpdate()
 		})
diff --git a/watcher.go b/watcher.go
deleted file mode 100644
index 86c3e653..00000000
--- a/watcher.go
+++ /dev/null
@@ -1,81 +0,0 @@
-//go:build go1.3 && !plan9 && !solaris
-// +build go1.3,!plan9,!solaris
-
-package main
-
-import (
-	"os"
-	"path/filepath"
-	"time"
-
-	"github.com/fsnotify/fsnotify"
-
-	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
-)
-
-// WaitForReplacement waits for a file to exist on disk and then starts a watch
-// for the file
-func WaitForReplacement(filename string, op fsnotify.Op,
-	watcher *fsnotify.Watcher) {
-	const sleepInterval = 50 * time.Millisecond
-
-	// Avoid a race when fsnofity.Remove is preceded by fsnotify.Chmod.
-	if op&fsnotify.Chmod != 0 {
-		time.Sleep(sleepInterval)
-	}
-	for {
-		if _, err := os.Stat(filename); err == nil {
-			if err := watcher.Add(filename); err == nil {
-				logger.Printf("watching resumed for %s", filename)
-				return
-			}
-		}
-		time.Sleep(sleepInterval)
-	}
-}
-
-// WatchForUpdates performs an action every time a file on disk is updated
-func WatchForUpdates(filename string, done <-chan bool, action func()) {
-	filename = filepath.Clean(filename)
-	watcher, err := fsnotify.NewWatcher()
-	if err != nil {
-		logger.Fatal("failed to create watcher for ", filename, ": ", err)
-	}
-	go func() {
-		defer func(w *fsnotify.Watcher) {
-			cerr := w.Close()
-			if cerr != nil {
-				logger.Fatalf("error closing watcher: %v", err)
-			}
-		}(watcher)
-		for {
-			select {
-			case <-done:
-				logger.Printf("Shutting down watcher for: %s", filename)
-				return
-			case event := <-watcher.Events:
-				// On Arch Linux, it appears Chmod events precede Remove events,
-				// which causes a race between action() and the coming Remove event.
-				// If the Remove wins, the action() (which calls
-				// UserMap.LoadAuthenticatedEmailsFile()) crashes when the file
-				// can't be opened.
-				if event.Op&(fsnotify.Remove|fsnotify.Rename|fsnotify.Chmod) != 0 {
-					logger.Printf("watching interrupted on event: %s", event)
-					err = watcher.Remove(filename)
-					if err != nil {
-						logger.Printf("error removing watcher on %s: %v", filename, err)
-					}
-					WaitForReplacement(filename, event.Op, watcher)
-				}
-				logger.Printf("reloading after event: %s", event)
-				action()
-			case err = <-watcher.Errors:
-				logger.Errorf("error watching %s: %s", filename, err)
-			}
-		}
-	}()
-	if err = watcher.Add(filename); err != nil {
-		logger.Fatal("failed to add ", filename, " to watcher: ", err)
-	}
-	logger.Printf("watching %s for updates", filename)
-}
diff --git a/watcher_unsupported.go b/watcher_unsupported.go
deleted file mode 100644
index 0f9e5f67..00000000
--- a/watcher_unsupported.go
+++ /dev/null
@@ -1,11 +0,0 @@
-//go:build !go1.3 || plan9 || solaris
-// +build !go1.3 plan9 solaris
-
-package main
-
-import "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
-
-func WatchForUpdates(filename string, done <-chan bool, action func()) {
-	logger.Errorf("file watching not implemented on this platform")
-	go func() { <-done }()
-}


Test patch:
diff --git a/main_test.go b/main_test.go
index 2ed44cb0..6a640817 100644
--- a/main_test.go
+++ b/main_test.go
@@ -3,7 +3,6 @@ package main
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"os"
 	"strings"
 	"time"
@@ -191,7 +190,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
 
 			if in.configContent != "" {
 				By("Writing the config to a temporary file", func() {
-					file, err := ioutil.TempFile("", "oauth2-proxy-test-config-XXXX.cfg")
+					file, err := os.CreateTemp("", "oauth2-proxy-test-config-XXXX.cfg")
 					Expect(err).ToNot(HaveOccurred())
 					defer file.Close()
 
@@ -204,7 +203,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
 
 			if in.alphaConfigContent != "" {
 				By("Writing the config to a temporary file", func() {
-					file, err := ioutil.TempFile("", "oauth2-proxy-test-alpha-config-XXXX.yaml")
+					file, err := os.CreateTemp("", "oauth2-proxy-test-alpha-config-XXXX.yaml")
 					Expect(err).ToNot(HaveOccurred())
 					defer file.Close()
 
diff --git a/oauthproxy_test.go b/oauthproxy_test.go
index b2abe379..0d8bc91a 100644
--- a/oauthproxy_test.go
+++ b/oauthproxy_test.go
@@ -6,7 +6,6 @@ import (
 	"encoding/base64"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -837,9 +836,9 @@ func NewProcessCookieTest(opts ProcessCookieTestOpts, modifiers ...OptionsModifi
 	}
 
 	groups := pcTest.opts.Providers[0].AllowedGroups
-	testProvider.AllowedGroups = make(map[string]struct{}, len(groups))
+	testProvider.ProviderData.AllowedGroups = make(map[string]struct{}, len(groups))
 	for _, group := range groups {
-		testProvider.AllowedGroups[group] = struct{}{}
+		testProvider.ProviderData.AllowedGroups[group] = struct{}{}
 	}
 	pcTest.proxy.provider = testProvider
 
@@ -1043,7 +1042,7 @@ func TestUserInfoEndpointAccepted(t *testing.T) {
 
 			test.proxy.ServeHTTP(test.rw, test.req)
 			assert.Equal(t, http.StatusOK, test.rw.Code)
-			bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+			bodyBytes, _ := io.ReadAll(test.rw.Body)
 			assert.Equal(t, tc.expectedResponse, string(bodyBytes))
 		})
 	}
@@ -1094,7 +1093,7 @@ func TestAuthOnlyEndpointAccepted(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusAccepted, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "", string(bodyBytes))
 }
 
@@ -1106,7 +1105,7 @@ func TestAuthOnlyEndpointUnauthorizedOnNoCookieSetError(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "Unauthorized\n", string(bodyBytes))
 }
 
@@ -1126,7 +1125,7 @@ func TestAuthOnlyEndpointUnauthorizedOnExpiration(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "Unauthorized\n", string(bodyBytes))
 }
 
@@ -1145,7 +1144,7 @@ func TestAuthOnlyEndpointUnauthorizedOnEmailValidationFailure(t *testing.T) {
 
 	test.proxy.ServeHTTP(test.rw, test.req)
 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
-	bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
+	bodyBytes, _ := io.ReadAll(test.rw.Body)
 	assert.Equal(t, "Unauthorized\n", string(bodyBytes))
 }
 
@@ -1533,7 +1532,8 @@ func (st *SignatureTest) Close() {
 
 // fakeNetConn simulates an http.Request.Body buffer that will be consumed
 // when it is read by the hmacauth.HmacAuth if not handled properly. See:
-//   https://github.com/18F/hmacauth/pull/4
+//
+//	https://github.com/18F/hmacauth/pull/4
 type fakeNetConn struct {
 	reqBody string
 }
@@ -1560,7 +1560,7 @@ func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) er
 
 	var bodyBuf io.ReadCloser
 	if body != "" {
-		bodyBuf = ioutil.NopCloser(&fakeNetConn{reqBody: body})
+		bodyBuf = io.NopCloser(&fakeNetConn{reqBody: body})
 	}
 	req := httptest.NewRequest(method, "/foo/bar", bodyBuf)
 	req.Header = st.header
@@ -2248,6 +2248,7 @@ func TestTrustedIPs(t *testing.T) {
 func Test_buildRoutesAllowlist(t *testing.T) {
 	type expectedAllowedRoute struct {
 		method      string
+		negate      bool
 		regexString string
 	}
 
@@ -2275,10 +2276,12 @@ func Test_buildRoutesAllowlist(t *testing.T) {
 			expectedRoutes: []expectedAllowedRoute{
 				{
 					method:      "",
+					negate:      false,
 					regexString: "^/foo/bar",
 				},
 				{
 					method:      "",
+					negate:      false,
 					regexString: "^/baz/[0-9]+/thing",
 				},
 			},
@@ -2293,28 +2296,45 @@ func Test_buildRoutesAllowlist(t *testing.T) {
 				"^/all/methods$",
 				"WEIRD=^/methods/are/allowed",
 				"PATCH=/second/equals?are=handled&just=fine",
+				"!=^/api",
+				"METHOD!=^/api",
 			},
 			expectedRoutes: []expectedAllowedRoute{
 				{
 					method:      "GET",
+					negate:      false,
 					regexString: "^/foo/bar",
 				},
 				{
 					method:      "POST",
+					negate:      false,
 					regexString: "^/baz/[0-9]+/thing",
 				},
 				{
 					method:      "",
+					negate:      false,
 					regexString: "^/all/methods$",
 				},
 				{
 					method:      "WEIRD",
+					negate:      false,
 					regexString: "^/methods/are/allowed",
 				},
 				{
 					method:      "PATCH",
+					negate:      false,
 					regexString: "/second/equals?are=handled&just=fine",
 				},
+				{
+					method:      "",
+					negate:      true,
+					regexString: "^/api",
+				},
+				{
+					method:      "METHOD",
+					negate:      true,
+					regexString: "^/api",
+				},
 			},
 			shouldError: false,
 		},
@@ -2394,12 +2414,123 @@ func Test_buildRoutesAllowlist(t *testing.T) {
 			for i, route := range routes {
 				assert.Greater(t, len(tc.expectedRoutes), i)
 				assert.Equal(t, route.method, tc.expectedRoutes[i].method)
+				assert.Equal(t, route.negate, tc.expectedRoutes[i].negate)
 				assert.Equal(t, route.pathRegex.String(), tc.expectedRoutes[i].regexString)
 			}
 		})
 	}
 }
 
+func TestApiRoutes(t *testing.T) {
+
+	ajaxAPIServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(200)
+		_, err := w.Write([]byte("AJAX API Request"))
+		if err != nil {
+			t.Fatal(err)
+		}
+	}))
+	t.Cleanup(ajaxAPIServer.Close)
+
+	apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(200)
+		_, err := w.Write([]byte("API Request"))
+		if err != nil {
+			t.Fatal(err)
+		}
+	}))
+	t.Cleanup(apiServer.Close)
+
+	uiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(200)
+		_, err := w.Write([]byte("API Request"))
+		if err != nil {
+			t.Fatal(err)
+		}
+	}))
+	t.Cleanup(uiServer.Close)
+
+	opts := baseTestOptions()
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   apiServer.URL,
+				Path: "/api",
+				URI:  apiServer.URL,
+			},
+			{
+				ID:   ajaxAPIServer.URL,
+				Path: "/ajaxapi",
+				URI:  ajaxAPIServer.URL,
+			},
+			{
+				ID:   uiServer.URL,
+				Path: "/ui",
+				URI:  uiServer.URL,
+			},
+		},
+	}
+	opts.APIRoutes = []string{
+		"^/api",
+	}
+	opts.SkipProviderButton = true
+	err := validation.Validate(opts)
+	assert.NoError(t, err)
+	proxy, err := NewOAuthProxy(opts, func(_ string) bool { return true })
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	testCases := []struct {
+		name           string
+		contentType    string
+		url            string
+		shouldRedirect bool
+	}{
+		{
+			name:           "AJAX request matching API regex",
+			contentType:    "application/json",
+			url:            "/api/v1/UserInfo",
+			shouldRedirect: false,
+		},
+		{
+			name:           "AJAX request not matching API regex",
+			contentType:    "application/json",
+			url:            "/ajaxapi/v1/UserInfo",
+			shouldRedirect: false,
+		},
+		{
+			name:           "Other Request matching API regex",
+			contentType:    "application/grpcwebtext",
+			url:            "/api/v1/UserInfo",
+			shouldRedirect: false,
+		},
+		{
+			name:           "UI request",
+			contentType:    "html",
+			url:            "/ui/index.html",
+			shouldRedirect: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			req, err := http.NewRequest("GET", tc.url, nil)
+			req.Header.Set("Accept", tc.contentType)
+			assert.NoError(t, err)
+
+			rw := httptest.NewRecorder()
+			proxy.ServeHTTP(rw, req)
+
+			if tc.shouldRedirect {
+				assert.Equal(t, 302, rw.Code)
+			} else {
+				assert.Equal(t, 401, rw.Code)
+			}
+		})
+	}
+}
+
 func TestAllowedRequest(t *testing.T) {
 	upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(200)
@@ -2475,6 +2606,212 @@ func TestAllowedRequest(t *testing.T) {
 			url:     "/skip/auth/routes/wrong/path",
 			allowed: false,
 		},
+		{
+			name:    "Route denied with wrong path and method",
+			method:  "POST",
+			url:     "/skip/auth/routes/wrong/path",
+			allowed: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			req, err := http.NewRequest(tc.method, tc.url, nil)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.allowed, proxy.isAllowedRoute(req))
+
+			rw := httptest.NewRecorder()
+			proxy.ServeHTTP(rw, req)
+
+			if tc.allowed {
+				assert.Equal(t, 200, rw.Code)
+				assert.Equal(t, "Allowed Request", rw.Body.String())
+			} else {
+				assert.Equal(t, 403, rw.Code)
+			}
+		})
+	}
+}
+
+func TestAllowedRequestNegateWithoutMethod(t *testing.T) {
+	upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(200)
+		_, err := w.Write([]byte("Allowed Request"))
+		if err != nil {
+			t.Fatal(err)
+		}
+	}))
+	t.Cleanup(upstreamServer.Close)
+
+	opts := baseTestOptions()
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   upstreamServer.URL,
+				Path: "/",
+				URI:  upstreamServer.URL,
+			},
+		},
+	}
+	opts.SkipAuthRoutes = []string{
+		"!=^/api", // any non-api routes
+		"POST=^/api/public-entity/?$",
+	}
+	err := validation.Validate(opts)
+	assert.NoError(t, err)
+	proxy, err := NewOAuthProxy(opts, func(_ string) bool { return true })
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	testCases := []struct {
+		name    string
+		method  string
+		url     string
+		allowed bool
+	}{
+		{
+			name:    "Some static file allowed",
+			method:  "GET",
+			url:     "/static/file.txt",
+			allowed: true,
+		},
+		{
+			name:    "POST to contact form allowed",
+			method:  "POST",
+			url:     "/contact",
+			allowed: true,
+		},
+		{
+			name:    "Regex POST allowed",
+			method:  "POST",
+			url:     "/api/public-entity",
+			allowed: true,
+		},
+		{
+			name:    "Regex POST with trailing slash allowed",
+			method:  "POST",
+			url:     "/api/public-entity/",
+			allowed: true,
+		},
+		{
+			name:    "Regex GET api route denied",
+			method:  "GET",
+			url:     "/api/users",
+			allowed: false,
+		},
+		{
+			name:    "Regex POST api route denied",
+			method:  "POST",
+			url:     "/api/users",
+			allowed: false,
+		},
+		{
+			name:    "Regex DELETE api route denied",
+			method:  "DELETE",
+			url:     "/api/users/1",
+			allowed: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			req, err := http.NewRequest(tc.method, tc.url, nil)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.allowed, proxy.isAllowedRoute(req))
+
+			rw := httptest.NewRecorder()
+			proxy.ServeHTTP(rw, req)
+
+			if tc.allowed {
+				assert.Equal(t, 200, rw.Code)
+				assert.Equal(t, "Allowed Request", rw.Body.String())
+			} else {
+				assert.Equal(t, 403, rw.Code)
+			}
+		})
+	}
+}
+
+func TestAllowedRequestNegateWithMethod(t *testing.T) {
+	upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(200)
+		_, err := w.Write([]byte("Allowed Request"))
+		if err != nil {
+			t.Fatal(err)
+		}
+	}))
+	t.Cleanup(upstreamServer.Close)
+
+	opts := baseTestOptions()
+	opts.UpstreamServers = options.UpstreamConfig{
+		Upstreams: []options.Upstream{
+			{
+				ID:   upstreamServer.URL,
+				Path: "/",
+				URI:  upstreamServer.URL,
+			},
+		},
+	}
+	opts.SkipAuthRoutes = []string{
+		"GET!=^/api", // any non-api routes
+		"POST=^/api/public-entity/?$",
+	}
+	err := validation.Validate(opts)
+	assert.NoError(t, err)
+	proxy, err := NewOAuthProxy(opts, func(_ string) bool { return true })
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	testCases := []struct {
+		name    string
+		method  string
+		url     string
+		allowed bool
+	}{
+		{
+			name:    "Some static file allowed",
+			method:  "GET",
+			url:     "/static/file.txt",
+			allowed: true,
+		},
+		{
+			name:    "POST to contact form not allowed",
+			method:  "POST",
+			url:     "/contact",
+			allowed: false,
+		},
+		{
+			name:    "Regex POST allowed",
+			method:  "POST",
+			url:     "/api/public-entity",
+			allowed: true,
+		},
+		{
+			name:    "Regex POST with trailing slash allowed",
+			method:  "POST",
+			url:     "/api/public-entity/",
+			allowed: true,
+		},
+		{
+			name:    "Regex GET api route denied",
+			method:  "GET",
+			url:     "/api/users",
+			allowed: false,
+		},
+		{
+			name:    "Regex POST api route denied",
+			method:  "POST",
+			url:     "/api/users",
+			allowed: false,
+		},
+		{
+			name:    "Regex DELETE api route denied",
+			method:  "DELETE",
+			url:     "/api/users/1",
+			allowed: false,
+		},
 	}
 
 	for _, tc := range testCases {
diff --git a/pkg/apis/options/legacy_options_test.go b/pkg/apis/options/legacy_options_test.go
index da5545d6..03f874ca 100644
--- a/pkg/apis/options/legacy_options_test.go
+++ b/pkg/apis/options/legacy_options_test.go
@@ -804,6 +804,7 @@ var _ = Describe("Legacy Options", func() {
 			keyPath             = "tls.key"
 			minVersion          = "TLS1.3"
 		)
+		cipherSuites := []string{"TLS_RSA_WITH_AES_128_GCM_SHA256", "TLS_RSA_WITH_AES_256_GCM_SHA384"}
 
 		var tlsConfig = &TLS{
 			Cert: &SecretSource{
@@ -820,6 +821,15 @@ var _ = Describe("Legacy Options", func() {
 			MinVersion: minVersion,
 		}
 
+		var tlsConfigCipherSuites = &TLS{
+			Cert: tlsConfig.Cert,
+			Key:  tlsConfig.Key,
+			CipherSuites: []string{
+				"TLS_RSA_WITH_AES_128_GCM_SHA256",
+				"TLS_RSA_WITH_AES_256_GCM_SHA384",
+			},
+		}
+
 		DescribeTable("should convert to app and metrics servers",
 			func(in legacyServersTableInput) {
 				appServer, metricsServer := in.legacyServer.convert()
@@ -860,6 +870,19 @@ var _ = Describe("Legacy Options", func() {
 					TLS:               tlsConfigMinVersion,
 				},
 			}),
+			Entry("with TLS options specified with CipherSuites", legacyServersTableInput{
+				legacyServer: LegacyServer{
+					HTTPAddress:     insecureAddr,
+					HTTPSAddress:    secureAddr,
+					TLSKeyFile:      keyPath,
+					TLSCertFile:     crtPath,
+					TLSCipherSuites: cipherSuites,
+				},
+				expectedAppServer: Server{
+					SecureBindAddress: secureAddr,
+					TLS:               tlsConfigCipherSuites,
+				},
+			}),
 			Entry("with metrics HTTP and HTTPS addresses", legacyServersTableInput{
 				legacyServer: LegacyServer{
 					HTTPAddress:          insecureAddr,
diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go
index 57efd926..cface514 100644
--- a/pkg/apis/options/load_test.go
+++ b/pkg/apis/options/load_test.go
@@ -3,7 +3,6 @@ package options
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"os"
 	"time"
 
@@ -118,7 +117,7 @@ var _ = Describe("Load", func() {
 
 				if o.configFile != nil {
 					By("Creating a config file")
-					configFile, err := ioutil.TempFile("", "oauth2-proxy-test-legacy-config-file")
+					configFile, err := os.CreateTemp("", "oauth2-proxy-test-legacy-config-file")
 					Expect(err).ToNot(HaveOccurred())
 					defer configFile.Close()
 
@@ -390,7 +389,7 @@ sub:
 
 				if in.configFile != nil {
 					By("Creating a config file")
-					configFile, err := ioutil.TempFile("", "oauth2-proxy-test-config-file")
+					configFile, err := os.CreateTemp("", "oauth2-proxy-test-config-file")
 					Expect(err).ToNot(HaveOccurred())
 					defer configFile.Close()
 
@@ -488,7 +487,7 @@ injectResponseHeaders:
 `)
 
 		By("Creating a config file")
-		configFile, err := ioutil.TempFile("", "oauth2-proxy-test-alpha-config-file")
+		configFile, err := os.CreateTemp("", "oauth2-proxy-test-alpha-config-file")
 		Expect(err).ToNot(HaveOccurred())
 		defer configFile.Close()
 
diff --git a/pkg/apis/options/util/util_test.go b/pkg/apis/options/util/util_test.go
index 8ee8cc30..b96c1453 100644
--- a/pkg/apis/options/util/util_test.go
+++ b/pkg/apis/options/util/util_test.go
@@ -1,7 +1,6 @@
 package util
 
 import (
-	"io/ioutil"
 	"os"
 	"path"
 
@@ -20,9 +19,9 @@ var _ = Describe("GetSecretValue", func() {
 		os.Setenv(secretEnvKey, secretEnvValue)
 
 		var err error
-		fileDir, err = ioutil.TempDir("", "oauth2-proxy-util-get-secret-value")
+		fileDir, err = os.MkdirTemp("", "oauth2-proxy-util-get-secret-value")
 		Expect(err).ToNot(HaveOccurred())
-		Expect(ioutil.WriteFile(path.Join(fileDir, "secret-file"), secretFileValue, 0600)).To(Succeed())
+		Expect(os.WriteFile(path.Join(fileDir, "secret-file"), secretFileValue, 0600)).To(Succeed())
 	})
 
 	AfterEach(func() {
diff --git a/pkg/app/pagewriter/error_page_test.go b/pkg/app/pagewriter/error_page_test.go
index f8e3bfb7..7c4f0136 100644
--- a/pkg/app/pagewriter/error_page_test.go
+++ b/pkg/app/pagewriter/error_page_test.go
@@ -3,7 +3,7 @@ package pagewriter
 import (
 	"errors"
 	"html/template"
-	"io/ioutil"
+	"io"
 	"net/http/httptest"
 
 	middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
@@ -36,7 +36,7 @@ var _ = Describe("Error Page Writer", func() {
 				AppError:    "Access Denied",
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Forbidden You do not have permission to access this resource. /prefix/ 403 /redirect 11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -50,7 +50,7 @@ var _ = Describe("Error Page Writer", func() {
 				AppError:    "Access Denied",
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Internal Server Error Oops! Something went wrong. For more information contact your server administrator. /prefix/ 500 /redirect 11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -68,7 +68,7 @@ var _ = Describe("Error Page Writer", func() {
 				},
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Forbidden An extra message: with more context. /prefix/ 403 /redirect 11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -82,7 +82,7 @@ var _ = Describe("Error Page Writer", func() {
 				AppError:    "Access Denied",
 			})
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Forbidden You do not have permission to access this resource. /prefix/ 403 /redirect &lt;script&gt;alert(1)&lt;/script&gt; Custom Footer Text v0.0.0-test"))
 		})
@@ -97,7 +97,7 @@ var _ = Describe("Error Page Writer", func() {
 			recorder := httptest.NewRecorder()
 			errorPage.ProxyErrorHandler(recorder, req, errors.New("some upstream error"))
 
-			body, err := ioutil.ReadAll(recorder.Result().Body)
+			body, err := io.ReadAll(recorder.Result().Body)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(string(body)).To(Equal("Bad Gateway There was a problem connecting to the upstream server. /prefix/ 502  11111111-2222-4333-8444-555555555555 Custom Footer Text v0.0.0-test"))
 		})
@@ -121,7 +121,7 @@ var _ = Describe("Error Page Writer", func() {
 					AppError:    "Debug error",
 				})
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("Debug error"))
 			})
@@ -136,7 +136,7 @@ var _ = Describe("Error Page Writer", func() {
 				recorder := httptest.NewRecorder()
 				errorPage.ProxyErrorHandler(recorder, req, errors.New("some upstream error"))
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("some upstream error"))
 			})
diff --git a/pkg/app/pagewriter/pagewriter_test.go b/pkg/app/pagewriter/pagewriter_test.go
index cfea153b..eca0c760 100644
--- a/pkg/app/pagewriter/pagewriter_test.go
+++ b/pkg/app/pagewriter/pagewriter_test.go
@@ -3,7 +3,7 @@ package pagewriter
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -50,7 +50,7 @@ var _ = Describe("Writer", func() {
 					AppError:    "Some debug error",
 				})
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(HavePrefix("\n<!DOCTYPE html>"))
 			})
@@ -59,7 +59,7 @@ var _ = Describe("Writer", func() {
 				recorder := httptest.NewRecorder()
 				writer.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(HavePrefix("\n<!DOCTYPE html>"))
 			})
@@ -70,14 +70,14 @@ var _ = Describe("Writer", func() {
 
 			BeforeEach(func() {
 				var err error
-				customDir, err = ioutil.TempDir("", "oauth2-proxy-pagewriter-test")
+				customDir, err = os.MkdirTemp("", "oauth2-proxy-pagewriter-test")
 				Expect(err).ToNot(HaveOccurred())
 
 				templateHTML := `Custom Template`
 				signInFile := filepath.Join(customDir, signInTemplateName)
-				Expect(ioutil.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
+				Expect(os.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
 				errorFile := filepath.Join(customDir, errorTemplateName)
-				Expect(ioutil.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
+				Expect(os.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
 
 				opts.TemplatesPath = customDir
 
@@ -97,7 +97,7 @@ var _ = Describe("Writer", func() {
 					AppError:    "Some debug error",
 				})
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("Custom Template"))
 			})
@@ -106,7 +106,7 @@ var _ = Describe("Writer", func() {
 				recorder := httptest.NewRecorder()
 				writer.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("Custom Template"))
 			})
@@ -117,12 +117,12 @@ var _ = Describe("Writer", func() {
 
 			BeforeEach(func() {
 				var err error
-				customDir, err = ioutil.TempDir("", "oauth2-proxy-pagewriter-test")
+				customDir, err = os.MkdirTemp("", "oauth2-proxy-pagewriter-test")
 				Expect(err).ToNot(HaveOccurred())
 
 				templateHTML := `{{ Custom Broken Template`
 				signInFile := filepath.Join(customDir, signInTemplateName)
-				Expect(ioutil.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
+				Expect(os.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
 
 				opts.TemplatesPath = customDir
 			})
@@ -155,7 +155,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
@@ -188,7 +188,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
@@ -218,7 +218,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
@@ -257,7 +257,7 @@ var _ = Describe("Writer", func() {
 
 				Expect(rw.Result().StatusCode).To(Equal(in.expectedStatus))
 
-				body, err := ioutil.ReadAll(rw.Result().Body)
+				body, err := io.ReadAll(rw.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(in.expectedBody))
 			},
diff --git a/pkg/app/pagewriter/sign_in_page_test.go b/pkg/app/pagewriter/sign_in_page_test.go
index 05c0b2e9..521e92d7 100644
--- a/pkg/app/pagewriter/sign_in_page_test.go
+++ b/pkg/app/pagewriter/sign_in_page_test.go
@@ -4,7 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"html/template"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -56,7 +56,7 @@ var _ = Describe("SignIn Page", func() {
 				recorder := httptest.NewRecorder()
 				signInPage.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal("/prefix/ My Provider Sign In Here Custom Footer Text v0.0.0-test /redirect true Logo Data"))
 			})
@@ -70,7 +70,7 @@ var _ = Describe("SignIn Page", func() {
 				recorder := httptest.NewRecorder()
 				signInPage.WriteSignInPage(recorder, request, "/redirect", http.StatusOK)
 
-				body, err := ioutil.ReadAll(recorder.Result().Body)
+				body, err := io.ReadAll(recorder.Result().Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(fmt.Sprintf("Internal Server Error | %s", testRequestID)))
 			})
@@ -84,12 +84,12 @@ var _ = Describe("SignIn Page", func() {
 
 		BeforeEach(func() {
 			var err error
-			customDir, err = ioutil.TempDir("", "oauth2-proxy-sign-in-page-test")
+			customDir, err = os.MkdirTemp("", "oauth2-proxy-sign-in-page-test")
 			Expect(err).ToNot(HaveOccurred())
 
 			for _, ext := range []string{".svg", ".png", ".jpg", ".jpeg", ".gif"} {
 				fileName := filepath.Join(customDir, fmt.Sprintf("logo%s", ext))
-				Expect(ioutil.WriteFile(fileName, []byte(fakeImageData), 0600)).To(Succeed())
+				Expect(os.WriteFile(fileName, []byte(fakeImageData), 0600)).To(Succeed())
 			}
 		})
 
diff --git a/pkg/app/pagewriter/static_pages_test.go b/pkg/app/pagewriter/static_pages_test.go
index f52451ba..9f317517 100644
--- a/pkg/app/pagewriter/static_pages_test.go
+++ b/pkg/app/pagewriter/static_pages_test.go
@@ -3,7 +3,7 @@ package pagewriter
 import (
 	"errors"
 	"html/template"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -27,11 +27,11 @@ var _ = Describe("Static Pages", func() {
 			template: errorTmpl,
 		}
 
-		customDir, err = ioutil.TempDir("", "oauth2-proxy-static-pages-test")
+		customDir, err = os.MkdirTemp("", "oauth2-proxy-static-pages-test")
 		Expect(err).ToNot(HaveOccurred())
 
 		robotsTxtFile := filepath.Join(customDir, robotsTxtName)
-		Expect(ioutil.WriteFile(robotsTxtFile, []byte(customRobots), 0400)).To(Succeed())
+		Expect(os.WriteFile(robotsTxtFile, []byte(customRobots), 0400)).To(Succeed())
 
 		request = httptest.NewRequest("", "http://127.0.0.1/", nil)
 		request = middlewareapi.AddRequestScope(request, &middlewareapi.RequestScope{
@@ -58,7 +58,7 @@ var _ = Describe("Static Pages", func() {
 					recorder := httptest.NewRecorder()
 					pageWriter.WriteRobotsTxt(recorder, request)
 
-					body, err := ioutil.ReadAll(recorder.Result().Body)
+					body, err := io.ReadAll(recorder.Result().Body)
 					Expect(err).ToNot(HaveOccurred())
 					Expect(string(body)).To(Equal(customRobots))
 
@@ -81,7 +81,7 @@ var _ = Describe("Static Pages", func() {
 					recorder := httptest.NewRecorder()
 					pageWriter.WriteRobotsTxt(recorder, request)
 
-					body, err := ioutil.ReadAll(recorder.Result().Body)
+					body, err := io.ReadAll(recorder.Result().Body)
 					Expect(err).ToNot(HaveOccurred())
 					Expect(string(body)).To(Equal(string(defaultRobotsTxt)))
 
@@ -94,7 +94,7 @@ var _ = Describe("Static Pages", func() {
 					}
 					pageWriter.WriteRobotsTxt(recorder, request)
 
-					body, err := ioutil.ReadAll(recorder.Result().Body)
+					body, err := io.ReadAll(recorder.Result().Body)
 					Expect(err).ToNot(HaveOccurred())
 					Expect(string(body)).To(Equal(string("Internal Server Error")))
 
diff --git a/pkg/app/pagewriter/templates_test.go b/pkg/app/pagewriter/templates_test.go
index cfcbcfca..a836c621 100644
--- a/pkg/app/pagewriter/templates_test.go
+++ b/pkg/app/pagewriter/templates_test.go
@@ -3,7 +3,6 @@ package pagewriter
 import (
 	"bytes"
 	"html/template"
-	"io/ioutil"
 	"os"
 	"path/filepath"
 
@@ -16,14 +15,14 @@ var _ = Describe("Templates", func() {
 
 	BeforeEach(func() {
 		var err error
-		customDir, err = ioutil.TempDir("", "oauth2-proxy-templates-test")
+		customDir, err = os.MkdirTemp("", "oauth2-proxy-templates-test")
 		Expect(err).ToNot(HaveOccurred())
 
 		templateHTML := `{{.TestString}} {{.TestString | ToLower}} {{.TestString | ToUpper}}`
 		signInFile := filepath.Join(customDir, signInTemplateName)
-		Expect(ioutil.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
+		Expect(os.WriteFile(signInFile, []byte(templateHTML), 0600)).To(Succeed())
 		errorFile := filepath.Join(customDir, errorTemplateName)
-		Expect(ioutil.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
+		Expect(os.WriteFile(errorFile, []byte(templateHTML), 0600)).To(Succeed())
 	})
 
 	AfterEach(func() {
@@ -162,7 +161,7 @@ var _ = Describe("Templates", func() {
 			Context("With an invalid sign_in template", func() {
 				BeforeEach(func() {
 					signInFile := filepath.Join(customDir, signInTemplateName)
-					Expect(ioutil.WriteFile(signInFile, []byte("{{"), 0600))
+					Expect(os.WriteFile(signInFile, []byte("{{"), 0600))
 				})
 
 				It("Should return an error when loading templates", func() {
@@ -175,7 +174,7 @@ var _ = Describe("Templates", func() {
 			Context("With an invalid error template", func() {
 				BeforeEach(func() {
 					errorFile := filepath.Join(customDir, errorTemplateName)
-					Expect(ioutil.WriteFile(errorFile, []byte("{{"), 0600))
+					Expect(os.WriteFile(errorFile, []byte("{{"), 0600))
 				})
 
 				It("Should return an error when loading templates", func() {
diff --git a/pkg/authentication/basic/htpasswd_test.go b/pkg/authentication/basic/htpasswd_test.go
index 0c2a197e..7e81f528 100644
--- a/pkg/authentication/basic/htpasswd_test.go
+++ b/pkg/authentication/basic/htpasswd_test.go
@@ -1,8 +1,11 @@
 package basic
 
 import (
+	"os"
+
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
+	"github.com/onsi/gomega/types"
 )
 
 const (
@@ -84,13 +87,89 @@ var _ = Describe("HTPasswd Suite", func() {
 				})
 
 				It("returns an error", func() {
-					Expect(err).To(MatchError("could not open htpasswd file: open ./test/htpasswd-doesnt-exist.txt: no such file or directory"))
+					Expect(err).To(MatchError("could not load htpasswd file: could not open htpasswd file: open ./test/htpasswd-doesnt-exist.txt: no such file or directory"))
 				})
 
 				It("returns a nil validator", func() {
 					Expect(validator).To(BeNil())
 				})
 			})
+
+			Context("htpasswd file is updated", func() {
+				const filePathPrefix = "htpasswd-file-updated-"
+				const adminUserHtpasswdEntry = "admin:$2y$05$SXWrNM7ldtbRzBvUC3VXyOvUeiUcP45XPwM93P5eeGOEPIiAZmJjC"
+				const user1HtpasswdEntry = "user1:$2y$05$/sZYJOk8.3Etg4V6fV7puuXfCJLmV5Q7u3xvKpjBSJUka.t2YtmmG"
+				var fileNames []string
+
+				AfterSuite(func() {
+					for _, v := range fileNames {
+						err := os.Remove(v)
+						Expect(err).ToNot(HaveOccurred())
+					}
+
+				})
+
+				type htpasswdUpdate struct {
+					testText              string
+					remove                bool
+					expectedLen           int
+					expectedGomegaMatcher types.GomegaMatcher
+				}
+
+				assertHtpasswdMapUpdate := func(hu htpasswdUpdate) {
+					var validator Validator
+					var file *os.File
+					var err error
+
+					// Create a temporary file with at least one entry
+					file, err = os.CreateTemp("", filePathPrefix)
+					Expect(err).ToNot(HaveOccurred())
+					_, err = file.WriteString(adminUserHtpasswdEntry + "\n")
+					Expect(err).ToNot(HaveOccurred())
+
+					validator, err = NewHTPasswdValidator(file.Name())
+					Expect(err).ToNot(HaveOccurred())
+
+					htpasswd, ok := validator.(*htpasswdMap)
+					Expect(ok).To(BeTrue())
+
+					if hu.remove {
+						// Overwrite the original file with another entry
+						err = os.WriteFile(file.Name(), []byte(user1HtpasswdEntry+"\n"), 0644)
+						Expect(err).ToNot(HaveOccurred())
+					} else {
+						// Add another entry to the original file in append mode
+						_, err = file.WriteString(user1HtpasswdEntry + "\n")
+						Expect(err).ToNot(HaveOccurred())
+					}
+
+					err = file.Close()
+					Expect(err).ToNot(HaveOccurred())
+
+					fileNames = append(fileNames, file.Name())
+
+					It("has the correct number of users", func() {
+						Expect(len(htpasswd.users)).To(Equal(hu.expectedLen))
+					})
+
+					It(hu.testText, func() {
+						Expect(htpasswd.Validate(adminUser, adminPassword)).To(hu.expectedGomegaMatcher)
+					})
+
+					It("new entry is present", func() {
+						Expect(htpasswd.Validate(user1, user1Password)).To(BeTrue())
+					})
+				}
+
+				Context("htpasswd entry is added", func() {
+					assertHtpasswdMapUpdate(htpasswdUpdate{"initial entry is present", false, 2, BeTrue()})
+				})
+
+				Context("htpasswd entry is removed", func() {
+					assertHtpasswdMapUpdate(htpasswdUpdate{"initial entry is removed", true, 1, BeFalse()})
+				})
+
+			})
 		})
 	})
 })
diff --git a/pkg/cookies/csrf_per_request_test.go b/pkg/cookies/csrf_per_request_test.go
new file mode 100644
index 00000000..915dc346
--- /dev/null
+++ b/pkg/cookies/csrf_per_request_test.go
@@ -0,0 +1,195 @@
+package cookies
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"time"
+
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("CSRF Cookie with non-fixed name Tests", func() {
+	var (
+		cookieOpts  *options.Cookie
+		publicCSRF  CSRF
+		privateCSRF *csrf
+	)
+
+	BeforeEach(func() {
+		cookieOpts = &options.Cookie{
+			Name:           cookieName,
+			Secret:         cookieSecret,
+			Domains:        []string{cookieDomain},
+			Path:           cookiePath,
+			Expire:         time.Hour,
+			Secure:         true,
+			HTTPOnly:       true,
+			CSRFPerRequest: true,
+			CSRFExpire:     time.Duration(5) * time.Minute,
+		}
+
+		var err error
+		publicCSRF, err = NewCSRF(cookieOpts, "verifier")
+		Expect(err).ToNot(HaveOccurred())
+
+		privateCSRF = publicCSRF.(*csrf)
+	})
+
+	Context("NewCSRF", func() {
+		It("makes unique nonces for OAuth and OIDC", func() {
+			Expect(privateCSRF.OAuthState).ToNot(BeEmpty())
+			Expect(privateCSRF.OIDCNonce).ToNot(BeEmpty())
+			Expect(privateCSRF.OAuthState).ToNot(Equal(privateCSRF.OIDCNonce))
+			Expect(privateCSRF.CodeVerifier).To(Equal("verifier"))
+		})
+
+		It("makes unique nonces between multiple CSRFs", func() {
+			other, err := NewCSRF(cookieOpts, "verifier")
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(privateCSRF.OAuthState).ToNot(Equal(other.(*csrf).OAuthState))
+			Expect(privateCSRF.OIDCNonce).ToNot(Equal(other.(*csrf).OIDCNonce))
+			Expect(privateCSRF.CodeVerifier).To(Equal("verifier"))
+		})
+	})
+
+	Context("CheckOAuthState and CheckOIDCNonce", func() {
+		It("checks that hashed versions match", func() {
+			privateCSRF.OAuthState = []byte(csrfState)
+			privateCSRF.OIDCNonce = []byte(csrfNonce)
+
+			stateHashed := encryption.HashNonce([]byte(csrfState))
+			nonceHashed := encryption.HashNonce([]byte(csrfNonce))
+
+			Expect(publicCSRF.CheckOAuthState(stateHashed)).To(BeTrue())
+			Expect(publicCSRF.CheckOIDCNonce(nonceHashed)).To(BeTrue())
+
+			Expect(publicCSRF.CheckOAuthState(csrfNonce)).To(BeFalse())
+			Expect(publicCSRF.CheckOIDCNonce(csrfState)).To(BeFalse())
+			Expect(publicCSRF.CheckOAuthState(csrfState + csrfNonce)).To(BeFalse())
+			Expect(publicCSRF.CheckOIDCNonce(csrfNonce + csrfState)).To(BeFalse())
+			Expect(publicCSRF.CheckOAuthState("")).To(BeFalse())
+			Expect(publicCSRF.CheckOIDCNonce("")).To(BeFalse())
+			Expect(publicCSRF.GetCodeVerifier()).To(Equal("verifier"))
+		})
+	})
+
+	Context("SetSessionNonce", func() {
+		It("sets the session.Nonce", func() {
+			session := &sessions.SessionState{}
+			publicCSRF.SetSessionNonce(session)
+			Expect(session.Nonce).To(Equal(privateCSRF.OIDCNonce))
+		})
+	})
+
+	Context("encodeCookie and decodeCSRFCookie", func() {
+		It("encodes and decodes to the same nonces", func() {
+			privateCSRF.OAuthState = []byte(csrfState)
+			privateCSRF.OIDCNonce = []byte(csrfNonce)
+
+			encoded, err := privateCSRF.encodeCookie()
+			Expect(err).ToNot(HaveOccurred())
+
+			cookie := &http.Cookie{
+				Name:  privateCSRF.cookieName(),
+				Value: encoded,
+			}
+			decoded, err := decodeCSRFCookie(cookie, cookieOpts)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(decoded).ToNot(BeNil())
+			Expect(decoded.OAuthState).To(Equal([]byte(csrfState)))
+			Expect(decoded.OIDCNonce).To(Equal([]byte(csrfNonce)))
+		})
+
+		It("signs the encoded cookie value", func() {
+			encoded, err := privateCSRF.encodeCookie()
+			Expect(err).ToNot(HaveOccurred())
+
+			cookie := &http.Cookie{
+				Name:  privateCSRF.cookieName(),
+				Value: encoded,
+			}
+
+			_, _, valid := encryption.Validate(cookie, cookieOpts.Secret, cookieOpts.Expire)
+			Expect(valid).To(BeTrue())
+		})
+	})
+
+	Context("Cookie Management", func() {
+		var req *http.Request
+
+		testNow := time.Unix(nowEpoch, 0)
+
+		BeforeEach(func() {
+			privateCSRF.time.Set(testNow)
+
+			req = &http.Request{
+				Method: http.MethodGet,
+				Proto:  "HTTP/1.1",
+				Host:   cookieDomain,
+
+				URL: &url.URL{
+					Scheme: "https",
+					Host:   cookieDomain,
+					Path:   cookiePath,
+				},
+			}
+		})
+
+		AfterEach(func() {
+			privateCSRF.time.Reset()
+		})
+
+		Context("SetCookie", func() {
+			It("adds the encoded CSRF cookie to a ResponseWriter", func() {
+				rw := httptest.NewRecorder()
+
+				_, err := publicCSRF.SetCookie(rw, req)
+				Expect(err).ToNot(HaveOccurred())
+
+				Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
+					fmt.Sprintf("%s=", privateCSRF.cookieName()),
+				))
+				Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
+					fmt.Sprintf(
+						"; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
+						cookiePath,
+						cookieDomain,
+						testCookieExpires(testNow.Add(cookieOpts.CSRFExpire)),
+					),
+				))
+			})
+		})
+
+		Context("ClearCookie", func() {
+			It("sets a cookie with an empty value in the past", func() {
+				rw := httptest.NewRecorder()
+
+				publicCSRF.ClearCookie(rw, req)
+
+				Expect(rw.Header().Get("Set-Cookie")).To(Equal(
+					fmt.Sprintf(
+						"%s=; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
+						privateCSRF.cookieName(),
+						cookiePath,
+						cookieDomain,
+						testCookieExpires(testNow.Add(time.Hour*-1)),
+					),
+				))
+			})
+		})
+
+		Context("cookieName", func() {
+			It("has the cookie options name as a base", func() {
+				Expect(privateCSRF.cookieName()).To(ContainSubstring(cookieName))
+			})
+		})
+	})
+})
diff --git a/pkg/cookies/csrf_test.go b/pkg/cookies/csrf_test.go
index 69fbfbb3..c4a461f0 100644
--- a/pkg/cookies/csrf_test.go
+++ b/pkg/cookies/csrf_test.go
@@ -23,13 +23,14 @@ var _ = Describe("CSRF Cookie Tests", func() {
 
 	BeforeEach(func() {
 		cookieOpts = &options.Cookie{
-			Name:     cookieName,
-			Secret:   cookieSecret,
-			Domains:  []string{cookieDomain},
-			Path:     cookiePath,
-			Expire:   time.Hour,
-			Secure:   true,
-			HTTPOnly: true,
+			Name:           cookieName,
+			Secret:         cookieSecret,
+			Domains:        []string{cookieDomain},
+			Path:           cookiePath,
+			Expire:         time.Hour,
+			Secure:         true,
+			HTTPOnly:       true,
+			CSRFPerRequest: false,
 		}
 
 		var err error
@@ -160,7 +161,7 @@ var _ = Describe("CSRF Cookie Tests", func() {
 						"; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
 						cookiePath,
 						cookieDomain,
-						testCookieExpires(testNow.Add(cookieOpts.Expire)),
+						testCookieExpires(testNow.Add(cookieOpts.CSRFExpire)),
 					),
 				))
 			})
diff --git a/pkg/header/header_suite_test.go b/pkg/header/header_suite_test.go
index 00f1a028..51685b20 100644
--- a/pkg/header/header_suite_test.go
+++ b/pkg/header/header_suite_test.go
@@ -1,7 +1,6 @@
 package header
 
 import (
-	"io/ioutil"
 	"os"
 	"path"
 	"testing"
@@ -26,9 +25,9 @@ func TestHeaderSuite(t *testing.T) {
 var _ = BeforeSuite(func() {
 	os.Setenv("SECRET_ENV", "super-secret-env")
 
-	dir, err := ioutil.TempDir("", "oauth2-proxy-header-suite")
+	dir, err := os.MkdirTemp("", "oauth2-proxy-header-suite")
 	Expect(err).ToNot(HaveOccurred())
-	Expect(ioutil.WriteFile(path.Join(dir, "secret-file"), []byte("super-secret-file"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "secret-file"), []byte("super-secret-file"), 0644)).To(Succeed())
 	filesDir = dir
 })
 
diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go
index 3c48cabe..dac49601 100644
--- a/pkg/http/server_test.go
+++ b/pkg/http/server_test.go
@@ -4,7 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -261,6 +261,40 @@ var _ = Describe("Server", func() {
 				expectHTTPListener: false,
 				expectTLSListener:  true,
 			}),
+			Entry("with an ipv4 valid https bind address, and valid TLS config with CipherSuites", &newServerTableInput{
+				opts: Opts{
+					Handler:           handler,
+					SecureBindAddress: "127.0.0.1:0",
+					TLS: &options.TLS{
+						Key:  &ipv4KeyDataSource,
+						Cert: &ipv4CertDataSource,
+						CipherSuites: []string{
+							"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
+							"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
+						},
+					},
+				},
+				expectedErr:        nil,
+				expectHTTPListener: false,
+				expectTLSListener:  true,
+			}),
+			Entry("with an ipv4 valid https bind address, and invalid TLS config with unknown CipherSuites", &newServerTableInput{
+				opts: Opts{
+					Handler:           handler,
+					SecureBindAddress: "127.0.0.1:0",
+					TLS: &options.TLS{
+						Key:  &ipv4KeyDataSource,
+						Cert: &ipv4CertDataSource,
+						CipherSuites: []string{
+							"TLS_RSA_WITH_RC4_64_SHA",
+							"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
+						},
+					},
+				},
+				expectedErr:        errors.New("error setting up TLS listener: could not parse cipher suites: unknown TLS cipher suite name specified \"TLS_RSA_WITH_RC4_64_SHA\""),
+				expectHTTPListener: false,
+				expectTLSListener:  true,
+			}),
 			Entry("with an ipv6 valid http bind address", &newServerTableInput{
 				opts: Opts{
 					Handler:     handler,
@@ -454,6 +488,40 @@ var _ = Describe("Server", func() {
 				expectHTTPListener: false,
 				expectTLSListener:  true,
 			}),
+			Entry("with an ipv6 valid https bind address, and valid TLS config with CipherSuites", &newServerTableInput{
+				opts: Opts{
+					Handler:           handler,
+					SecureBindAddress: "[::1]:0",
+					TLS: &options.TLS{
+						Key:  &ipv4KeyDataSource,
+						Cert: &ipv4CertDataSource,
+						CipherSuites: []string{
+							"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
+							"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
+						},
+					},
+				},
+				expectedErr:        nil,
+				expectHTTPListener: false,
+				expectTLSListener:  true,
+			}),
+			Entry("with an ipv6 valid https bind address, and invalid TLS config with unknown CipherSuites", &newServerTableInput{
+				opts: Opts{
+					Handler:           handler,
+					SecureBindAddress: "[::1]:0",
+					TLS: &options.TLS{
+						Key:  &ipv4KeyDataSource,
+						Cert: &ipv4CertDataSource,
+						CipherSuites: []string{
+							"TLS_RSA_WITH_RC4_64_SHA",
+							"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
+						},
+					},
+				},
+				expectedErr:        errors.New("error setting up TLS listener: could not parse cipher suites: unknown TLS cipher suite name specified \"TLS_RSA_WITH_RC4_64_SHA\""),
+				expectHTTPListener: false,
+				expectTLSListener:  true,
+			}),
 		)
 	})
 
@@ -497,7 +565,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -551,7 +619,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -622,7 +690,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -637,7 +705,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -693,7 +761,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -747,7 +815,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -818,7 +886,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
@@ -833,7 +901,7 @@ var _ = Describe("Server", func() {
 				Expect(err).ToNot(HaveOccurred())
 				Expect(resp.StatusCode).To(Equal(http.StatusOK))
 
-				body, err := ioutil.ReadAll(resp.Body)
+				body, err := io.ReadAll(resp.Body)
 				Expect(err).ToNot(HaveOccurred())
 				Expect(string(body)).To(Equal(hello))
 			})
diff --git a/pkg/requests/builder_test.go b/pkg/requests/builder_test.go
index 0c0f0d03..552741f7 100644
--- a/pkg/requests/builder_test.go
+++ b/pkg/requests/builder_test.go
@@ -285,7 +285,7 @@ func assertSuccessfulRequest(builder func() Builder, expectedRequest testHTTPReq
 
 		BeforeEach(func() {
 			var err error
-			response, err = builder().Do().UnmarshalJSON()
+			response, err = builder().Do().UnmarshalSimpleJSON()
 			Expect(err).ToNot(HaveOccurred())
 		})
 
@@ -340,7 +340,7 @@ func assertRequestError(builder func() Builder, errorMessage string) {
 
 	Context("UnmarshalJSON", func() {
 		It("returns an error", func() {
-			resp, err := builder().Do().UnmarshalJSON()
+			resp, err := builder().Do().UnmarshalSimpleJSON()
 			Expect(err).To(MatchError(ContainSubstring(errorMessage)))
 			Expect(resp).To(BeNil())
 		})
@@ -368,7 +368,7 @@ func assertJSONError(builder func() Builder, errorMessage string) {
 
 	Context("UnmarshalJSON", func() {
 		It("returns an error", func() {
-			resp, err := builder().Do().UnmarshalJSON()
+			resp, err := builder().Do().UnmarshalSimpleJSON()
 			Expect(err).To(MatchError(ContainSubstring(errorMessage)))
 			Expect(resp).To(BeNil())
 		})
diff --git a/pkg/requests/requests_suite_test.go b/pkg/requests/requests_suite_test.go
index 9c22e4d0..5bc684b0 100644
--- a/pkg/requests/requests_suite_test.go
+++ b/pkg/requests/requests_suite_test.go
@@ -3,7 +3,7 @@ package requests
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"net/http"
 	"net/http/httptest"
@@ -82,7 +82,7 @@ func toTestHTTPRequest(req *http.Request) (testHTTPRequest, error) {
 	requestBody := []byte{}
 	if req.Body != http.NoBody {
 		var err error
-		requestBody, err = ioutil.ReadAll(req.Body)
+		requestBody, err = io.ReadAll(req.Body)
 		if err != nil {
 			return testHTTPRequest{}, err
 		}
diff --git a/pkg/requests/result_test.go b/pkg/requests/result_test.go
index 2d5c95ca..70c2814a 100644
--- a/pkg/requests/result_test.go
+++ b/pkg/requests/result_test.go
@@ -198,7 +198,7 @@ var _ = Describe("Result suite", func() {
 
 		DescribeTable("with a result",
 			func(in unmarshalJSONTableInput) {
-				j, err := in.result.UnmarshalJSON()
+				j, err := in.result.UnmarshalSimpleJSON()
 				if in.expectedErr != nil {
 					Expect(err).To(MatchError(in.expectedErr))
 					Expect(j).To(BeNil())
diff --git a/pkg/sessions/redis/redis_store_test.go b/pkg/sessions/redis/redis_store_test.go
index 9f0bd0a9..14155043 100644
--- a/pkg/sessions/redis/redis_store_test.go
+++ b/pkg/sessions/redis/redis_store_test.go
@@ -12,7 +12,7 @@ import (
 
 	"github.com/Bose/minisentinel"
 	"github.com/alicebob/miniredis/v2"
-	"github.com/go-redis/redis/v8"
+	"github.com/go-redis/redis/v9"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
 	sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
diff --git a/pkg/upstream/upstream_suite_test.go b/pkg/upstream/upstream_suite_test.go
index e91da077..95eb6811 100644
--- a/pkg/upstream/upstream_suite_test.go
+++ b/pkg/upstream/upstream_suite_test.go
@@ -3,7 +3,7 @@ package upstream
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"net/http"
 	"net/http/httptest"
@@ -35,12 +35,12 @@ func TestUpstreamSuite(t *testing.T) {
 
 var _ = BeforeSuite(func() {
 	// Set up files for serving via file servers
-	dir, err := ioutil.TempDir("", "oauth2-proxy-upstream-suite")
+	dir, err := os.MkdirTemp("", "oauth2-proxy-upstream-suite")
 	Expect(err).ToNot(HaveOccurred())
-	Expect(ioutil.WriteFile(path.Join(dir, "foo"), []byte("foo"), 0644)).To(Succeed())
-	Expect(ioutil.WriteFile(path.Join(dir, "bar"), []byte("bar"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "foo"), []byte("foo"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "bar"), []byte("bar"), 0644)).To(Succeed())
 	Expect(os.Mkdir(path.Join(dir, "subdir"), os.ModePerm)).To(Succeed())
-	Expect(ioutil.WriteFile(path.Join(dir, "subdir", "baz"), []byte("baz"), 0644)).To(Succeed())
+	Expect(os.WriteFile(path.Join(dir, "subdir", "baz"), []byte("baz"), 0644)).To(Succeed())
 	filesDir = dir
 
 	// Set up a webserver that reflects requests
@@ -148,7 +148,7 @@ func toTestHTTPRequest(req *http.Request) (testHTTPRequest, error) {
 	requestBody := []byte{}
 	if req.Body != http.NoBody {
 		var err error
-		requestBody, err = ioutil.ReadAll(req.Body)
+		requestBody, err = io.ReadAll(req.Body)
 		if err != nil {
 			return testHTTPRequest{}, err
 		}
diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go
index d5f7860b..b8eff502 100644
--- a/pkg/util/util_test.go
+++ b/pkg/util/util_test.go
@@ -3,7 +3,6 @@ package util
 import (
 	"crypto/x509"
 	"encoding/pem"
-	"io/ioutil"
 	"os"
 	"testing"
 
@@ -183,7 +182,7 @@ WrW4JMzLaGDtoHxRNNfo8E7fGkQ=
 )
 
 func makeTestCertFile(t *testing.T, pem, dir string) *os.File {
-	file, err := ioutil.TempFile(dir, "test-certfile")
+	file, err := os.CreateTemp(dir, "test-certfile")
 	assert.NoError(t, err)
 	_, err = file.Write([]byte(pem))
 	assert.NoError(t, err)
@@ -196,7 +195,7 @@ func TestGetCertPool_NoRoots(t *testing.T) {
 }
 
 func TestGetCertPool(t *testing.T) {
-	tempDir, err := ioutil.TempDir("", "certtest")
+	tempDir, err := os.MkdirTemp("", "certtest")
 	assert.NoError(t, err)
 	defer func(path string) {
 		rerr := os.RemoveAll(path)
diff --git a/pkg/validation/allowlist_test.go b/pkg/validation/allowlist_test.go
index 4600a718..1519493b 100644
--- a/pkg/validation/allowlist_test.go
+++ b/pkg/validation/allowlist_test.go
@@ -29,7 +29,7 @@ var _ = Describe("Allowlist", func() {
 			opts := &options.Options{
 				SkipAuthRoutes: r.routes,
 			}
-			Expect(validateRoutes(opts)).To(ConsistOf(r.errStrings))
+			Expect(validateAuthRoutes(opts)).To(ConsistOf(r.errStrings))
 		},
 		Entry("Valid regex routes", &validateRoutesTableInput{
 			routes: []string{
@@ -61,7 +61,7 @@ var _ = Describe("Allowlist", func() {
 			opts := &options.Options{
 				SkipAuthRegex: r.regexes,
 			}
-			Expect(validateRegexes(opts)).To(ConsistOf(r.errStrings))
+			Expect(validateAuthRegexes(opts)).To(ConsistOf(r.errStrings))
 		},
 		Entry("Valid regex routes", &validateRegexesTableInput{
 			regexes: []string{
diff --git a/pkg/validation/common_test.go b/pkg/validation/common_test.go
index 10c179b9..809e1cf6 100644
--- a/pkg/validation/common_test.go
+++ b/pkg/validation/common_test.go
@@ -1,7 +1,6 @@
 package validation
 
 import (
-	"io/ioutil"
 	"os"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
@@ -18,7 +17,7 @@ var _ = Describe("Common", func() {
 	BeforeEach(func() {
 		validSecretSourceValue = []byte("This is a secret source value")
 		Expect(os.Setenv(validSecretSourceEnv, "This is a secret source env")).To(Succeed())
-		tmp, err := ioutil.TempFile("", "oauth2-proxy-secret-source-test")
+		tmp, err := os.CreateTemp("", "oauth2-proxy-secret-source-test")
 		Expect(err).ToNot(HaveOccurred())
 		defer tmp.Close()
 
diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go
index 3d22ec9e..fa45221c 100644
--- a/pkg/validation/options_test.go
+++ b/pkg/validation/options_test.go
@@ -2,7 +2,6 @@ package validation
 
 import (
 	"crypto"
-	"io/ioutil"
 	"net/url"
 	"os"
 	"strings"
@@ -205,7 +204,7 @@ func TestRealClientIPHeader(t *testing.T) {
 }
 
 func TestProviderCAFilesError(t *testing.T) {
-	file, err := ioutil.TempFile("", "absent.*.crt")
+	file, err := os.CreateTemp("", "absent.*.crt")
 	assert.NoError(t, err)
 	assert.NoError(t, file.Close())
 	assert.NoError(t, os.Remove(file.Name()))
diff --git a/pkg/validation/sessions_test.go b/pkg/validation/sessions_test.go
index f1943288..0a0f0fd5 100644
--- a/pkg/validation/sessions_test.go
+++ b/pkg/validation/sessions_test.go
@@ -192,8 +192,8 @@ var _ = Describe("Sessions", func() {
 		invalidPasswordDelMsg     = "unable to delete the redis initialization key: WRONGPASS invalid username-password pair"
 		unreachableRedisSetMsg    = "unable to set a redis initialization key: dial tcp 127.0.0.1:65535: connect: connection refused"
 		unreachableRedisDelMsg    = "unable to delete the redis initialization key: dial tcp 127.0.0.1:65535: connect: connection refused"
-		unreachableSentinelSetMsg = "unable to set a redis initialization key: redis: all sentinels are unreachable"
-		unrechableSentinelDelMsg  = "unable to delete the redis initialization key: redis: all sentinels are unreachable"
+		unreachableSentinelSetMsg = "unable to set a redis initialization key: redis: all sentinels specified in configuration are unreachable"
+		unrechableSentinelDelMsg  = "unable to delete the redis initialization key: redis: all sentinels specified in configuration are unreachable"
 	)
 
 	type redisStoreTableInput struct {
diff --git a/providers/azure_test.go b/providers/azure_test.go
index b198d019..27d70b88 100644
--- a/providers/azure_test.go
+++ b/providers/azure_test.go
@@ -55,6 +55,7 @@ func testAzureProvider(hostname string, opts options.AzureOptions) *AzureProvide
 			ProtectedResource: &url.URL{},
 			Scope:             "",
 			EmailClaim:        "email",
+			GroupsClaim:       "groups",
 			Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
 				"https://issuer.example.com",
 				fakeAzureKeySetStub{},
@@ -133,14 +134,9 @@ func TestAzureSetTenant(t *testing.T) {
 	p := testAzureProvider("", options.AzureOptions{Tenant: "example"})
 	assert.Equal(t, "Azure", p.Data().ProviderName)
 	assert.Equal(t, "example", p.Tenant)
-	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize",
-		p.Data().LoginURL.String())
-	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token",
-		p.Data().RedeemURL.String())
-	assert.Equal(t, "https://graph.microsoft.com/v1.0/me",
-		p.Data().ProfileURL.String())
-	assert.Equal(t, "https://graph.microsoft.com",
-		p.Data().ProtectedResource.String())
+	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/authorize", p.Data().LoginURL.String())
+	assert.Equal(t, "https://login.microsoftonline.com/example/oauth2/token", p.Data().RedeemURL.String())
+	assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ProfileURL.String())
 	assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ValidateURL.String())
 	assert.Equal(t, "openid", p.Data().Scope)
 }
@@ -151,10 +147,25 @@ func testAzureBackend(payload string, accessToken, refreshToken string) *httptes
 
 func testAzureBackendWithError(payload string, accessToken, refreshToken string, injectError bool) *httptest.Server {
 	path := "/v1.0/me"
+	pathGroups := path + "/transitiveMemberOf/microsoft.graph.group"
 
 	return httptest.NewServer(http.HandlerFunc(
 		func(w http.ResponseWriter, r *http.Request) {
-			if (r.URL.Path != path) && r.Method != http.MethodPost {
+			if r.URL.Path == pathGroups && r.Method == http.MethodGet {
+				w.Write([]byte(`{
+					"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups(displayName,id)",
+					"value": [
+						{
+							"displayName": "aa",
+							"id": "11111111-2222-3333-4444-555555555555"
+						},
+						{
+							"displayName": "bb",
+							"id": "555555555555-4444-3333-2222-11111111"
+						}
+					]
+				}`))
+			} else if (r.URL.Path != path) && r.Method != http.MethodPost {
 				w.WriteHeader(404)
 			} else if r.Method == http.MethodPost && r.Body != nil {
 				if injectError {
@@ -183,7 +194,7 @@ func TestAzureProviderEnrichSession(t *testing.T) {
 	}{
 		{
 			Description:             "should return email using mail property from Azure backend",
-			PayloadFromAzureBackend: `{ "mail": "user@windows.net" }`,
+			PayloadFromAzureBackend: `{ "mail": "user@windows.net", "groups": ["aa", "bb"] }`,
 			ExpectedEmail:           "user@windows.net",
 		},
 		{
@@ -199,17 +210,17 @@ func TestAzureProviderEnrichSession(t *testing.T) {
 		{
 			Description:             "should return error when Azure backend doesn't return email information",
 			PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": null }`,
-			ExpectedError:           fmt.Errorf("unable to get email address: %v", errors.New("type assertion to string failed")),
+			ExpectedError:           fmt.Errorf("unable to get email address from profile URL: %v", errors.New("empty email address: type assertion to string failed")),
 		},
 		{
 			Description:             "should return specific error when unable to get email",
 			PayloadFromAzureBackend: `{ "mail": null, "otherMails": [], "userPrincipalName": "" }`,
-			ExpectedError:           errors.New("unable to get email address"),
+			ExpectedError:           errors.New("unable to get email address from profile URL: empty email address: <nil>"),
 		},
 		{
 			Description:             "should return error when otherMails from Azure backend is not a valid type",
 			PayloadFromAzureBackend: `{ "mail": null, "otherMails": "", "userPrincipalName": null }`,
-			ExpectedError:           fmt.Errorf("unable to get email address: %v", errors.New("type assertion to string failed")),
+			ExpectedError:           fmt.Errorf("unable to get email address from profile URL: %v", errors.New("empty email address: type assertion to string failed")),
 		},
 		{
 			Description:   "should not query profile api when email is already set in session",
@@ -224,13 +235,13 @@ func TestAzureProviderEnrichSession(t *testing.T) {
 				b    *httptest.Server
 				host string
 			)
-			if testCase.PayloadFromAzureBackend != "" {
-				b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
-				defer b.Close()
 
-				bURL, _ := url.Parse(b.URL)
-				host = bURL.Host
-			}
+			b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
+			defer b.Close()
+
+			bURL, _ := url.Parse(b.URL)
+			host = bURL.Host
+
 			p := testAzureProvider(host, options.AzureOptions{})
 			session := CreateAuthorizedSession()
 			session.Email = testCase.Email
@@ -250,18 +261,21 @@ func TestAzureProviderRedeem(t *testing.T) {
 		EmailFromAccessToken string
 		IsIDTokenMalformed   bool
 		InjectRedeemURLError bool
+		Groups               []string
 	}{
 		{
 			Name:             "with id_token returned",
 			EmailFromIDToken: "foo1@example.com",
 			RefreshToken:     "some_refresh_token",
 			ExpiresOn:        time.Now().Add(time.Hour),
+			Groups:           []string{"aa", "bb"},
 		},
 		{
 			Name:                 "without id_token returned, fallback to access token",
 			EmailFromAccessToken: "foo2@example.com",
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
+			Groups:               []string{"aa", "bb"},
 		},
 		{
 			Name:                 "id_token malformed, fallback to access token",
@@ -269,6 +283,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
 			IsIDTokenMalformed:   true,
+			Groups:               []string{"aa", "bb"},
 		},
 		{
 			Name:                 "both id_token and access tokens are valid, return email from id_token",
@@ -276,6 +291,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 			EmailFromAccessToken: "foo3@example.com",
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
+			Groups:               []string{"aa", "bb"},
 		},
 		{
 			Name:                 "redeem URL failed, should return error",
@@ -284,6 +300,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 			RefreshToken:         "some_refresh_token",
 			ExpiresOn:            time.Now().Add(time.Hour),
 			InjectRedeemURLError: true,
+			Groups:               []string{"aa", "bb"},
 		},
 	}
 
@@ -295,7 +312,9 @@ func TestAzureProviderRedeem(t *testing.T) {
 				var err error
 				token := idTokenClaims{
 					StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
-					Email:          testCase.EmailFromIDToken}
+					Email:          testCase.EmailFromIDToken,
+					Groups:         []string{"aa", "bb"},
+				}
 				idTokenString, err = newSignedTestIDToken(token)
 				assert.NoError(t, err)
 			}
@@ -303,7 +322,9 @@ func TestAzureProviderRedeem(t *testing.T) {
 				var err error
 				token := idTokenClaims{
 					StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
-					Email:          testCase.EmailFromAccessToken}
+					Email:          testCase.EmailFromAccessToken,
+					Groups:         []string{"aa", "bb"},
+				}
 				accessTokenString, err = newSignedTestIDToken(token)
 				assert.NoError(t, err)
 			}
@@ -335,6 +356,7 @@ func TestAzureProviderRedeem(t *testing.T) {
 				assert.Equal(t, accessTokenString, s.AccessToken)
 				assert.Equal(t, testCase.ExpiresOn.Unix(), s.ExpiresOn.Unix())
 				assert.Equal(t, testCase.RefreshToken, s.RefreshToken)
+				assert.Equal(t, testCase.Groups, s.Groups)
 				if testCase.EmailFromIDToken != "" {
 					assert.Equal(t, testCase.EmailFromIDToken, s.Email)
 				} else {
@@ -345,13 +367,24 @@ func TestAzureProviderRedeem(t *testing.T) {
 	}
 }
 
-func TestAzureProviderProtectedResourceConfigured(t *testing.T) {
+func TestAzureProviderProtectedResourceConfiguredOAuthV1(t *testing.T) {
 	p := testAzureProvider("", options.AzureOptions{})
 	p.ProtectedResource, _ = url.Parse("http://my.resource.test")
 	result := p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{})
 	assert.Contains(t, result, "resource="+url.QueryEscape("http://my.resource.test"))
 }
 
+func TestAzureProviderProtectedResourceConfiguredOAuthV2(t *testing.T) {
+	p := testAzureProvider("", options.AzureOptions{})
+	testURL := "http://my.resource.test"
+	p.ProtectedResource, _ = url.Parse(testURL)
+	p.isV2Endpoint = true
+	result, _ := url.Parse(p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{}))
+	parsedQuery, _ := url.ParseQuery(result.RawQuery)
+	assert.NotContains(t, parsedQuery["scope"], " "+testURL)
+	assert.NotContains(t, result.RawQuery, "resource="+url.QueryEscape(testURL))
+}
+
 func TestAzureProviderRefresh(t *testing.T) {
 	email := "foo@example.com"
 	subject := "foo"
diff --git a/providers/google_test.go b/providers/google_test.go
index 780420e4..6ec25d84 100644
--- a/providers/google_test.go
+++ b/providers/google_test.go
@@ -166,7 +166,6 @@ func TestGoogleProviderGroupValidator(t *testing.T) {
 	}
 }
 
-//
 func TestGoogleProviderGetEmailAddressInvalidEncoding(t *testing.T) {
 	p := newGoogleProvider(t)
 	body, err := json.Marshal(redeemResponse{
diff --git a/providers/providers_test.go b/providers/providers_test.go
index ed2a10a0..a69a0641 100644
--- a/providers/providers_test.go
+++ b/providers/providers_test.go
@@ -1,7 +1,6 @@
 package providers
 
 import (
-	"io/ioutil"
 	"os"
 	"testing"
 
@@ -43,7 +42,7 @@ func TestClientSecretFileOptionFails(t *testing.T) {
 func TestClientSecretFileOption(t *testing.T) {
 	g := NewWithT(t)
 
-	f, err := ioutil.TempFile("", "client_secret_temp_file_")
+	f, err := os.CreateTemp("", "client_secret_temp_file_")
 	g.Expect(err).ToNot(HaveOccurred())
 
 	clientSecretFileName := f.Name()
diff --git a/validator_test.go b/validator_test.go
index 0856ed90..976c0d7d 100644
--- a/validator_test.go
+++ b/validator_test.go
@@ -1,7 +1,6 @@
 package main
 
 import (
-	"io/ioutil"
 	"os"
 	"strings"
 	"testing"
@@ -18,7 +17,7 @@ type ValidatorTest struct {
 func NewValidatorTest(t *testing.T) *ValidatorTest {
 	vt := &ValidatorTest{}
 	var err error
-	f, err := ioutil.TempFile("", "test_auth_emails_")
+	f, err := os.CreateTemp("", "test_auth_emails_")
 	if err != nil {
 		t.Fatalf("failed to create temp file: %v", err)
 	}