Repository: ory/kratos
Pull request #411 (2020-05-13 15:53:01)

Enhance, stabilize and document OpenID Connect strategy


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 30820a1af..9091c83ac 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -20,7 +20,7 @@ jobs:
           - TEST_MAILHOG_API=http://127.0.0.1:8025
           - TEST_SELFSERVICE_OIDC_HYDRA_ADMIN=http://127.0.0.1:4445
           - TEST_SELFSERVICE_OIDC_HYDRA_PUBLIC=http://127.0.0.1:4444
-          - TEST_SELFSERVICE_OIDC_HYDRA_INTEGRATION_ADDR=127.0.0.1:4499
+          - TEST_SELFSERVICE_OIDC_HYDRA_INTEGRATION_ADDR=http://127.0.0.1:4499
           - TEST_DATABASE_POSTGRESQL=postgres://test:test@localhost:5432/postgres?sslmode=disable
           - TEST_DATABASE_MYSQL=mysql://root:test@(localhost:3306)/mysql?parseTime=true&multiStatements=true
           - TEST_DATABASE_COCKROACHDB=cockroach://root@localhost:26257/defaultdb?sslmode=disable
diff --git a/.schema/api.swagger.json b/.schema/api.swagger.json
index 222007f2c..0d50cc033 100755
--- a/.schema/api.swagger.json
+++ b/.schema/api.swagger.json
@@ -427,6 +427,35 @@
         }
       }
     },
+    "/self-service/browser/flows/registration/strategies/oidc/settings/connections": {
+      "post": {
+        "description": "This endpoint completes a browser-based settings flow. This is usually achieved by POSTing data to this\nendpoint.\n\n\u003e This endpoint is NOT INTENDED for API clients and only works with browsers (Chrome, Firefox, ...) and HTML Forms.\n\nMore information can be found at [ORY Kratos User Settings \u0026 Profile Management Documentation](https://www.ory.sh/docs/next/kratos/self-service/flows/user-settings-profile-management).",
+        "consumes": [
+          "application/json",
+          "application/x-www-form-urlencoded"
+        ],
+        "schemes": [
+          "http",
+          "https"
+        ],
+        "tags": [
+          "public"
+        ],
+        "summary": "Complete the browser-based settings flow for the OpenID Connect strategy",
+        "operationId": "completeSelfServiceBrowserSettingsOIDCSettingsFlow",
+        "responses": {
+          "302": {
+            "description": "Empty responses are sent when, for example, resources are deleted. The HTTP status code for empty responses is\ntypically 201."
+          },
+          "500": {
+            "description": "genericError",
+            "schema": {
+              "$ref": "#/definitions/genericError"
+            }
+          }
+        }
+      }
+    },
     "/self-service/browser/flows/requests/login": {
       "get": {
         "description": "This endpoint returns a login request's context with, for example, error details and\nother information.\n\nWhen accessing this endpoint through ORY Kratos' Public API, ensure that cookies are set as they are required for CSRF to work. To prevent\ntoken scanning attacks, the public endpoint does not return 404 status codes to prevent scanning attacks.\n\nMore information can be found at [ORY Kratos User Login and User Registration Documentation](https://www.ory.sh/docs/next/kratos/self-service/flows/user-login-user-registration).",
@@ -1010,6 +1039,12 @@
         }
       }
     },
+    "Errors": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/Error"
+      }
+    },
     "Identity": {
       "type": "object",
       "required": [
@@ -1044,6 +1079,17 @@
       "type": "object",
       "title": "JSONRawMessage represents a json.RawMessage that works well with JSON, SQL, and Swagger."
     },
+    "ProviderCredentialsConfig": {
+      "type": "object",
+      "properties": {
+        "provider": {
+          "type": "string"
+        },
+        "subject": {
+          "type": "string"
+        }
+      }
+    },
     "RequestMethodConfig": {
       "type": "object",
       "required": [
@@ -1175,8 +1221,7 @@
       "type": "object",
       "required": [
         "name",
-        "type",
-        "required"
+        "type"
       ],
       "properties": {
         "disabled": {
@@ -1184,11 +1229,7 @@
           "type": "boolean"
         },
         "errors": {
-          "description": "Errors contains all validation errors this particular field has caused.",
-          "type": "array",
-          "items": {
-            "$ref": "#/definitions/Error"
-          }
+          "$ref": "#/definitions/Errors"
         },
         "name": {
           "description": "Name is the equivalent of `\u003cinput name=\"{{.Name}}\"\u003e`",
diff --git a/Makefile b/Makefile
index afde2317c..6862a3db0 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ export GO111MODULE := on
 export PATH := $(pwd)/.bin:${PATH}
 
 deps:
-ifneq ("v0", $(shell cat .bin/.lock))
+ifneq ("v0","$(shell cat .bin/.lock)")
 		curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b .bin/ v1.24.0
 		bash <(curl https://raw.githubusercontent.com/ory/hydra/master/install.sh) -b .bin/ v1.4.10
 		go build -o .bin/go-acc github.com/ory/go-acc
diff --git a/UPGRADE.md b/UPGRADE.md
index b479443c1..372cb1cf4 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -12,11 +12,15 @@ track of future changes.
 
 ### OpenID Connect and OAuth2 now official!
 
-Using social profiles to sign up and log in is now as easy as setting two config entries! Check out
+Using social profiles to sign up and log in is now as easy as setting two config entries! Check out:
 
 - [The OpenID Connect & OAuth2 Credential Documentation](https://www.ory.sh/kratos/docs/concepts/credentials/openid-connect-oidc-oauth2);
 - [The Flow Description](https://www.ory.sh/kratos/docs/concepts/credentials/openid-connect-oidc-oauth2);
 - [The "Sign in with GitHub" Guide](https://www.ory.sh/kratos/docs/guides/sign-in-with-github)!
+- [Jsonnet Reference](https://www.ory.sh/kratos/docs/reference/jsonnet)!
+
+If you've been using the preview and have users with this strategy signed up, create an issue,
+and we'll provide guidance on upgrading.
 
 ## v0.2.0-alpha.2
 
diff --git a/cmd/daemon/serve.go b/cmd/daemon/serve.go
index f39d41f36..8aecef01c 100644
--- a/cmd/daemon/serve.go
+++ b/cmd/daemon/serve.go
@@ -42,19 +42,7 @@ func servePublic(d driver.Driver, wg *sync.WaitGroup, cmd *cobra.Command, args [
 	r := d.Registry()
 
 	router := x.NewRouterPublic()
-	r.LoginHandler().RegisterPublicRoutes(router)
-	r.RegistrationHandler().RegisterPublicRoutes(router)
-	r.LogoutHandler().RegisterPublicRoutes(router)
-	r.SettingsHandler().RegisterPublicRoutes(router)
-	r.LoginStrategies().RegisterPublicRoutes(router)
-	r.SettingsStrategies().RegisterPublicRoutes(router)
-	r.RegistrationStrategies().RegisterPublicRoutes(router)
-	r.SessionHandler().RegisterPublicRoutes(router)
-	r.SelfServiceErrorHandler().RegisterPublicRoutes(router)
-	r.SchemaHandler().RegisterPublicRoutes(router)
-	r.VerificationHandler().RegisterPublicRoutes(router)
-	r.HealthHandler().SetRoutes(router.Router, false)
-
+	r.RegisterPublicRoutes(router)
 	n.Use(NewNegroniLoggerMiddleware(l.(*logrus.Logger), "public#"+c.SelfPublicURL().String()))
 	n.Use(sqa(cmd, d))
 
@@ -90,16 +78,7 @@ func serveAdmin(d driver.Driver, wg *sync.WaitGroup, cmd *cobra.Command, args []
 	r := d.Registry()
 
 	router := x.NewRouterAdmin()
-	r.RegistrationHandler().RegisterAdminRoutes(router)
-	r.LoginHandler().RegisterAdminRoutes(router)
-	r.SchemaHandler().RegisterAdminRoutes(router)
-	r.VerificationHandler().RegisterAdminRoutes(router)
-	r.SettingsHandler().RegisterAdminRoutes(router)
-	r.IdentityHandler().RegisterAdminRoutes(router)
-	r.SessionHandler().RegisterAdminRoutes(router)
-	r.HealthHandler().SetRoutes(router.Router, true)
-	r.SelfServiceErrorHandler().RegisterAdminRoutes(router)
-
+	r.RegisterAdminRoutes(router)
 	n.Use(NewNegroniLoggerMiddleware(l.(*logrus.Logger), "admin#"+c.SelfAdminURL().String()))
 	n.Use(sqa(cmd, d))
 
diff --git a/continuity/manager.go b/continuity/manager.go
index 8584668ce..c3f50a479 100644
--- a/continuity/manager.go
+++ b/continuity/manager.go
@@ -19,7 +19,7 @@ type ManagementProvider interface {
 
 type Manager interface {
 	Pause(ctx context.Context, w http.ResponseWriter, r *http.Request, name string, opts ...ManagerOption) error
-	Continue(ctx context.Context, r *http.Request, name string, opts ...ManagerOption) (*Container, error)
+	Continue(ctx context.Context, w http.ResponseWriter, r *http.Request, name string, opts ...ManagerOption) (*Container, error)
 	Abort(ctx context.Context, w http.ResponseWriter, r *http.Request, name string) error
 }
 
@@ -28,13 +28,15 @@ type managerOptions struct {
 	ttl        time.Duration
 	payload    json.RawMessage
 	payloadRaw interface{}
+	cleanUp    bool
 }
 
 type ManagerOption func(*managerOptions) error
 
 func newManagerOptions(opts []ManagerOption) (*managerOptions, error) {
 	var o = &managerOptions{
-		ttl: time.Minute,
+		ttl:     time.Minute,
+		cleanUp: true,
 	}
 	for _, opt := range opts {
 		if err := opt(o); err != nil {
@@ -44,6 +46,13 @@ func newManagerOptions(opts []ManagerOption) (*managerOptions, error) {
 	return o, nil
 }
 
+func DontCleanUp() ManagerOption {
+	return func(o *managerOptions) error {
+		o.cleanUp = false
+		return nil
+	}
+}
+
 func WithIdentity(i *identity.Identity) ManagerOption {
 	return func(o *managerOptions) error {
 		if i != nil {
diff --git a/continuity/manager_cookie.go b/continuity/manager_cookie.go
index 6636dd569..f30ce14ae 100644
--- a/continuity/manager_cookie.go
+++ b/continuity/manager_cookie.go
@@ -60,7 +60,7 @@ func (m *ManagerCookie) Pause(ctx context.Context, w http.ResponseWriter, r *htt
 	return nil
 }
 
-func (m *ManagerCookie) Continue(ctx context.Context, r *http.Request, name string, opts ...ManagerOption) (*Container, error) {
+func (m *ManagerCookie) Continue(ctx context.Context, w http.ResponseWriter, r *http.Request, name string, opts ...ManagerOption) (*Container, error) {
 	container, err := m.container(ctx, r, name)
 	if err != nil {
 		return nil, err
@@ -85,6 +85,10 @@ func (m *ManagerCookie) Continue(ctx context.Context, r *http.Request, name stri
 		return nil, err
 	}
 
+	if err := x.SessionUnsetKey(w, r, m.d.CookieManager(), cookieName, name); err != nil {
+		return nil, err
+	}
+
 	return container, nil
 }
 
@@ -123,9 +127,7 @@ func (m ManagerCookie) Abort(ctx context.Context, w http.ResponseWriter, r *http
 		return err
 	}
 
-	if err := x.SessionPersistValues(w, r, m.d.CookieManager(), cookieName, map[string]interface{}{
-		name: "",
-	}); err != nil {
+	if err := x.SessionUnsetKey(w, r, m.d.CookieManager(), cookieName, name); err != nil {
 		return err
 	}
 
diff --git a/cypress.json b/cypress.json
index c5691a9ac..4cc5af132 100644
--- a/cypress.json
+++ b/cypress.json
@@ -1,3 +1,9 @@
 {
-  "chromeWebSecurity": false
+  "chromeWebSecurity": false,
+  "fixturesFolder": "test/e2e/cypress/fixtures",
+  "integrationFolder": "test/e2e/cypress/integration",
+  "pluginsFile": "test/e2e/cypress/plugins/index.js",
+  "supportFile": "test/e2e/cypress/support/index.js",
+  "videosFolder": "test/e2e/cypress/videos",
+  "screenshotsFolder": "test/e2e/cypress/screenshots"
 }
diff --git a/docs/docs/concepts/selfservice-flow-completion.md b/docs/docs/concepts/selfservice-flow-completion.md
index 54a5a1f5d..fe01c2427 100644
--- a/docs/docs/concepts/selfservice-flow-completion.md
+++ b/docs/docs/concepts/selfservice-flow-completion.md
@@ -13,7 +13,7 @@ successful response modes:
 
 Browser requests, identified by the `Accept: text/html` header, complete with a
 redirection flow. If no redirection URL is set for the flow, the Default
-Redirect URL will be used:
+Redirect URL will be used for most flows (e.g. login, registration):
 
 ```yaml file="path/to/my/kratos.config.yml"
 urls:
@@ -61,6 +61,59 @@ urls:
     - https://www.myapp.com/
 ```
 
+### Post-Login Redirection
+
+Post-login redirection considers the following configuration keys:
+
+```yaml file="path/to/my/kratos.config.yml"
+urls:
+  default_redirect_to: https://end-up-here-per-default/
+
+selfservice:
+  login:
+    after:
+      # overrides url.default_redirect_to
+      default_redirect_to: https://this-is-overridden-by-password/
+      password:
+        # overrides selfservice.login.after.default_redirect_to
+        default_redirect_to: https://end-up-here-after-login-with-password/
+```
+
+### Post-Registration Redirection
+
+Post-login redirection considers the following configuration keys:
+
+```yaml file="path/to/my/kratos.config.yml"
+urls:
+  default_redirect_to: https://end-up-here-per-default/
+
+selfservice:
+  registration:
+    after:
+      # overrides url.default_redirect_to
+      default_redirect_to: https://this-is-overridden-by-password/
+      password:
+        # overrides selfservice.registration.after.default_redirect_to
+        default_redirect_to: https://end-up-here-after-registration-with-password/
+```
+
+### Post-Settings Redirection
+
+Post-settings redirection **does not use** the `urls.default_redirect_to` configuration key. Instead
+the redirect ends at the same Settings UI with the same Settings Request ID and key `update_successful`
+set to `true`. If the listed keys are set, the redirection will end up at the specified values:
+
+```yaml file="path/to/my/kratos.config.yml"
+selfservice:
+  settings:
+    after:
+      # overrides url.default_redirect_to
+      default_redirect_to: https://this-is-overridden-by-password/
+      password:
+        # overrides selfservice.settings.after.default_redirect_to
+        default_redirect_to: https://end-up-here-after-settings-with-password/
+```
+
 ## JSON
 
 This feature is currently in prototype phase and will be documented at a later
diff --git a/docs/docs/guides/sign-in-with-github-google-facebook-linkedin.mdx b/docs/docs/guides/sign-in-with-github-google-facebook-linkedin.mdx
index 42f6e03a3..5ecc6be25 100644
--- a/docs/docs/guides/sign-in-with-github-google-facebook-linkedin.mdx
+++ b/docs/docs/guides/sign-in-with-github-google-facebook-linkedin.mdx
@@ -21,9 +21,9 @@ $ make quickstart
 To set up "Sign in with GitHub" you must create a
 [GitHub OAuth2 Client](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/)
 and set the "Authorization callback URL" to
-`http://127.0.0.1:4455/.ory/kratos/public/self-service/browser/flows/registration/strategies/oidc/callback/github`.
+`http://127.0.0.1:4455/.ory/kratos/public/self-service/browser/flows/strategies/oidc/callback/github`.
 The pattern of this URL is
-`http(s)://<domain-of-ory-kratos>:<public-port>/self-service/browser/flows/registration/strategies/oidc/callback/<provider-id>`.
+`http(s)://<domain-of-ory-kratos>:<public-port>/self-service/browser/flows/strategies/oidc/callback/<provider-id>`.
 The provider ID must point to the provider's ID set in the ORY Kratos
 configuration file (explained in paragraphs).
 
@@ -36,7 +36,7 @@ and adds that data to `std.extVar('claims')`. Check out what data is available
 at
 [GitHub's Scope Docs](https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/).
 Not all GitHub fields are supported however. Check the list of supported fields
-[in the source code](https://github.com/ory/kratos/blob/v0.2.1-alpha.1/selfservice/strategy/oidc/provider_github.go#L72-L80).
+[in the source code](https://github.com/ory/kratos/blob/v0.2.1-alpha.1/selfservice/strategy/oidc/provider_github.go#L72-L98).
 
 :::
 
@@ -47,12 +47,20 @@ you must also create a Jsonnet code snippet for the provider. Save the code in
 The following schema takes `email_primary` and maps it to `traits.email`:
 
 ```json title="contrib/quickstart/kratos/email-password/oidc.github.jsonnet"
-local claims = std.extVar('claims');
+local claims = {
+  email_verified: false
+} + std.extVar('claims');
 
 {
   identity: {
     traits: {
-      [if "email_primary" in claims then "email" else null]: claims.email_primary,
+      // Allowing unverified email addresses enables account
+      // enumeration attacks, especially if the value is used for
+      // e.g. verification or as a password login identifier.
+      //
+      // Therefore we only return the email if it (a) exists and (b) is marked verified
+      // by GitHub.
+      [if "email_primary" in claims && claims.email_verified then "email" else null]: claims.email_primary,
     },
   },
 }
diff --git a/docs/docs/self-service/flows/verify-email-account-activation.mdx b/docs/docs/self-service/flows/verify-email-account-activation.mdx
index 1d21b1cc0..719b5a9f8 100644
--- a/docs/docs/self-service/flows/verify-email-account-activation.mdx
+++ b/docs/docs/self-service/flows/verify-email-account-activation.mdx
@@ -29,7 +29,7 @@ already known. Depending on the result, one of the two flows will be executed:
     src={useBaseUrl('img/docs/email-verify-known.png')}
   />
 
-This prevents Account Enumeration Attacks at is is not possible for a threat
+This prevents Account Enumeration Attacks at is not possible for a threat
 agent to determine if an account exists or not based on the verification flow.
 
 The emails are using templates that can be customised as explained in
diff --git a/docs/docs/self-service/strategies/openid-connect-social-sign-in-oauth2.md b/docs/docs/self-service/strategies/openid-connect-social-sign-in-oauth2.md
index 60289dca8..b046cf449 100644
--- a/docs/docs/self-service/strategies/openid-connect-social-sign-in-oauth2.md
+++ b/docs/docs/self-service/strategies/openid-connect-social-sign-in-oauth2.md
@@ -52,3 +52,50 @@ future release of ORY Kratos.
 
 Please be aware that OpenID Connect providers always require a Browser, with the
 exception of "Sign in with Apple" on recent iOS versions.
+
+## Security and Defenses
+
+### Account Enumeration
+
+:::warn
+
+This feature is a work in progress and is tracked as
+[kratos#133](https://github.com/ory/kratos/issues/133).
+
+:::
+
+Scenario: In some cases you might want to use the email address returned by e.g.
+the GitHub profile to be added to your user's account. You might also want to use
+it as an email + password identifier so that the user is able to log in
+with a password as well. An attacker is able to exploit that by creating a social
+profile on another site (e.g. Google) and use the victims email address to set it up
+(this only works when the victim does not yet have an account with that email at Google).
+The attacker can then use that "spoofed" social profile to try and sign up with your ORY Kratos
+installation. Because the victim's email address is already known to ORY Kratos, the
+attacker might be able to observe the behavior (e.g. seeing an error page) and come
+to the conclusion that the victim already has an account at the website.
+
+Mitigation: This attack surface is rather small and requires a lot of effort, including
+forging an e.g. Google profile, and can fail at several stages. To completely mitigate
+any chance of that happening, only accept email addresses that are marked as verified
+in your Jsonnet code:
+
+```json title="contrib/quickstart/kratos/email-password/oidc.github.jsonnet"
+local claims = {
+  email_verified: false
+} + std.extVar('claims');
+
+{
+  identity: {
+    traits: {
+      // Allowing unverified email addresses enables account
+      // enumeration attacks, especially if the value is used for
+      // e.g. verification or as a password login identifier.
+      //
+      // Therefore we only return the email if it (a) exists and (b) is marked verified
+      // by GitHub.
+      [if "email_primary" in claims && claims.email_verified then "email" else null]: claims.email_primary,
+    },
+  },
+}
+```
diff --git a/docs/docs/self-service/strategies/username-email-password.mdx b/docs/docs/self-service/strategies/username-email-password.mdx
index 44509c0b0..e79137df5 100644
--- a/docs/docs/self-service/strategies/username-email-password.mdx
+++ b/docs/docs/self-service/strategies/username-email-password.mdx
@@ -680,7 +680,7 @@ To mitigate this attack, the following strategies need to be deployed:
   link to the login screen.
 
 If you wish to mitigate account enumeration attacks, it is important to note
-that you can not sign in users directly after sign up! Depending on the type of
+that you cannot sign in users directly after sign up! Depending on the type of
 service you provide, you might not care about this specific attack in which case
 direct login after sign up would be ok.
 
diff --git a/driver/registry.go b/driver/registry.go
index c15ab8966..ed66a7c36 100644
--- a/driver/registry.go
+++ b/driver/registry.go
@@ -52,6 +52,10 @@ type Registry interface {
 	HealthHandler() *healthx.Handler
 	CookieManager() sessions.Store
 
+	RegisterRoutes(public *x.RouterPublic, admin *x.RouterAdmin)
+	RegisterPublicRoutes(public *x.RouterPublic)
+	RegisterAdminRoutes(admin *x.RouterAdmin)
+
 	x.CSRFProvider
 	x.WriterProvider
 	x.LoggingProvider
@@ -72,6 +76,7 @@ type Registry interface {
 	identity.PoolProvider
 	identity.PrivilegedPoolProvider
 	identity.ManagementProvider
+	identity.ActiveCredentialsCounterStrategyProvider
 
 	schema.HandlerProvider
 
diff --git a/driver/registry_default.go b/driver/registry_default.go
index 3a836c0e0..6e1b2450f 100644
--- a/driver/registry_default.go
+++ b/driver/registry_default.go
@@ -108,10 +108,11 @@ type RegistryDefault struct {
 
 	selfserviceLogoutHandler *logout.Handler
 
-	selfserviceStrategies  []interface{}
-	loginStrategies        []login.Strategy
-	registrationStrategies []registration.Strategy
-	profileStrategies      []settings.Strategy
+	selfserviceStrategies              []interface{}
+	loginStrategies                    []login.Strategy
+	activeCredentialsCounterStrategies []identity.ActiveCredentialsCounter
+	registrationStrategies             []registration.Strategy
+	profileStrategies                  []settings.Strategy
 
 	buildVersion string
 	buildHash    string
@@ -120,6 +121,38 @@ type RegistryDefault struct {
 	csrfTokenGenerator x.CSRFToken
 }
 
+func (m *RegistryDefault) RegisterPublicRoutes(router *x.RouterPublic) {
+	m.LoginHandler().RegisterPublicRoutes(router)
+	m.RegistrationHandler().RegisterPublicRoutes(router)
+	m.LogoutHandler().RegisterPublicRoutes(router)
+	m.SettingsHandler().RegisterPublicRoutes(router)
+	m.LoginStrategies().RegisterPublicRoutes(router)
+	m.SettingsStrategies().RegisterPublicRoutes(router)
+	m.RegistrationStrategies().RegisterPublicRoutes(router)
+	m.SessionHandler().RegisterPublicRoutes(router)
+	m.SelfServiceErrorHandler().RegisterPublicRoutes(router)
+	m.SchemaHandler().RegisterPublicRoutes(router)
+	m.VerificationHandler().RegisterPublicRoutes(router)
+	m.HealthHandler().SetRoutes(router.Router, false)
+}
+
+func (m *RegistryDefault) RegisterAdminRoutes(router *x.RouterAdmin) {
+	m.RegistrationHandler().RegisterAdminRoutes(router)
+	m.LoginHandler().RegisterAdminRoutes(router)
+	m.SchemaHandler().RegisterAdminRoutes(router)
+	m.VerificationHandler().RegisterAdminRoutes(router)
+	m.SettingsHandler().RegisterAdminRoutes(router)
+	m.IdentityHandler().RegisterAdminRoutes(router)
+	m.SessionHandler().RegisterAdminRoutes(router)
+	m.SelfServiceErrorHandler().RegisterAdminRoutes(router)
+	m.HealthHandler().SetRoutes(router.Router, true)
+}
+
+func (m *RegistryDefault) RegisterRoutes(public *x.RouterPublic, admin *x.RouterAdmin) {
+	m.RegisterAdminRoutes(admin)
+	m.RegisterPublicRoutes(public)
+}
+
 func NewRegistryDefault() *RegistryDefault {
 	return &RegistryDefault{}
 }
@@ -235,6 +268,17 @@ func (m *RegistryDefault) LoginStrategies() login.Strategies {
 	return m.loginStrategies
 }
 
+func (m *RegistryDefault) ActiveCredentialsCounterStrategies() []identity.ActiveCredentialsCounter {
+	if len(m.activeCredentialsCounterStrategies) == 0 {
+		for _, strategy := range m.selfServiceStrategies() {
+			if s, ok := strategy.(identity.ActiveCredentialsCounter); ok {
+				m.activeCredentialsCounterStrategies = append(m.activeCredentialsCounterStrategies, s)
+			}
+		}
+	}
+	return m.activeCredentialsCounterStrategies
+}
+
 func (m *RegistryDefault) IdentityValidator() *identity.Validator {
 	if m.identityValidator == nil {
 		m.identityValidator = identity.NewValidator(m, m.c)
diff --git a/go.mod b/go.mod
index 5c70fe87b..2ab2fc32c 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
 	github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
 	github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0
+	github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869
 	github.com/bxcodec/faker v2.0.1+incompatible
 	github.com/cenkalti/backoff v2.2.1+incompatible
 	github.com/coreos/go-oidc v2.2.1+incompatible
@@ -47,6 +48,7 @@ require (
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 // indirect
 	github.com/ory/analytics-go/v4 v4.0.0
 	github.com/ory/dockertest v3.3.5+incompatible
+	github.com/ory/dockertest/v3 v3.5.4
 	github.com/ory/go-acc v0.1.0
 	github.com/ory/go-convenience v0.1.0
 	github.com/ory/graceful v0.1.1
@@ -54,8 +56,8 @@ require (
 	github.com/ory/jsonschema/v3 v3.0.1
 	github.com/ory/mail/v3 v3.0.0
 	github.com/ory/sdk/swagutil v0.0.0-20200505101021-3f40b808145c
-	github.com/ory/viper v1.7.4
-	github.com/ory/x v0.0.120
+	github.com/ory/viper v1.7.5
+	github.com/ory/x v0.0.122
 	github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2
 	github.com/pkg/errors v0.9.1
 	github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e
@@ -67,7 +69,7 @@ require (
 	github.com/tidwall/gjson v1.3.5
 	github.com/tidwall/sjson v1.0.4
 	github.com/urfave/negroni v1.0.0
-	golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59
+	golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	golang.org/x/tools v0.0.0-20200331202046-9d5940d49312
 	gopkg.in/go-playground/validator.v9 v9.28.0
diff --git a/go.sum b/go.sum
index f15484b8e..894cae134 100644
--- a/go.sum
+++ b/go.sum
@@ -914,13 +914,15 @@ github.com/ory/sdk/swagutil v0.0.0-20200505101021-3f40b808145c/go.mod h1:Ufg1eAy
 github.com/ory/viper v1.5.6/go.mod h1:TYmpFpKLxjQwvT4f0QPpkOn4sDXU1kDgAwJpgLYiQ28=
 github.com/ory/viper v1.7.4 h1:3RWBt7Pq9kSFNxLaRT0ljNdbtaWisCQG1cLPn2Yd4UY=
 github.com/ory/viper v1.7.4/go.mod h1:T6sodNZKNGPpashUOk7EtXz2isovz8oCd57GNVkkNmE=
+github.com/ory/viper v1.7.5 h1:+xVdq7SU3e1vNaCsk/ixsfxE4zylk1TJUiJrY647jUE=
+github.com/ory/viper v1.7.5/go.mod h1:ypOuyJmEUb3oENywQZRgeAMwqgOyDqwboO1tj3DjTaM=
 github.com/ory/x v0.0.84/go.mod h1:RXLPBG7B+hAViONVg0sHwK+U/ie1Y/NeXrq1JcARfoE=
 github.com/ory/x v0.0.85/go.mod h1:s44V8t3xyjWZREcU+mWlp4h302rTuM4aLXcW+y5FbQ8=
 github.com/ory/x v0.0.88/go.mod h1:wrnJRjIfYXFY/AUiuUlcIUpLBDxFtWc+8x6toAeLZXU=
 github.com/ory/x v0.0.93/go.mod h1:lfcTaGXpTZs7IEQAW00r9EtTCOxD//SiP5uWtNiz31g=
 github.com/ory/x v0.0.110/go.mod h1:DJfkE3GdakhshNhw4zlKoRaL/ozg/lcTahA9OCih2BE=
-github.com/ory/x v0.0.120 h1:f/oaWkVVD6iDVW/VGN/vXCKwFo0tecfjbaAIGCbAnf8=
-github.com/ory/x v0.0.120/go.mod h1:ImFneVZHXPCeI1EYXLzRylIkOUMQnWT9Xwuasd8QHxw=
+github.com/ory/x v0.0.122 h1:CumxV2IpCh0wFyGJQYb4y/Wpoc3H4TnrLO1XCamLip4=
+github.com/ory/x v0.0.122/go.mod h1:72UkrchuWldt9oIcOME79PuqIUuJPIXMNHXCKWHMrQk=
 github.com/parnurzeal/gorequest v0.2.15/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
 github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
@@ -1168,6 +1170,8 @@ golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw=
+golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
diff --git a/identity/credentials.go b/identity/credentials.go
index d17a1ced6..8c623abd4 100644
--- a/identity/credentials.go
+++ b/identity/credentials.go
@@ -71,6 +71,16 @@ type (
 
 	// swagger:ignore
 	CredentialIdentifierCollection []CredentialIdentifier
+
+	// swagger:ignore
+	ActiveCredentialsCounter interface {
+		CountActiveCredentials(cc map[CredentialsType]Credentials) (int, error)
+	}
+
+	// swagger:ignore
+	ActiveCredentialsCounterStrategyProvider interface {
+		ActiveCredentialsCounterStrategies() []ActiveCredentialsCounter
+	}
 )
 
 func (c CredentialsTypeTable) TableName() string {
diff --git a/identity/identity.go b/identity/identity.go
index 55f48e838..dfb7bc0c4 100644
--- a/identity/identity.go
+++ b/identity/identity.go
@@ -119,6 +119,20 @@ func (i *Identity) GetCredentials(t CredentialsType) (*Credentials, bool) {
 	return nil, false
 }
 
+func (i *Identity) ParseCredentials(t CredentialsType, config interface{}) (*Credentials, error) {
+	i.lock().RLock()
+	defer i.lock().RUnlock()
+
+	if c, ok := i.Credentials[t]; ok {
+		if err := json.Unmarshal(c.Config, config); err != nil {
+			return nil, errors.WithStack(err)
+		}
+		return &c, nil
+	}
+
+	return nil, errors.Errorf("identity does not have credential type %s", t)
+}
+
 func (i *Identity) CopyWithoutCredentials() *Identity {
 	var ii = *i
 	ii.Credentials = nil
diff --git a/internal/httpclient/client/public/complete_self_service_browser_settings_o_id_c_settings_flow_parameters.go b/internal/httpclient/client/public/complete_self_service_browser_settings_o_id_c_settings_flow_parameters.go
new file mode 100644
index 000000000..8e832a3ea
--- /dev/null
+++ b/internal/httpclient/client/public/complete_self_service_browser_settings_o_id_c_settings_flow_parameters.go
@@ -0,0 +1,112 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package public
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/runtime"
+	cr "github.com/go-openapi/runtime/client"
+	"github.com/go-openapi/strfmt"
+)
+
+// NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams creates a new CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams object
+// with the default values initialized.
+func NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams() *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams {
+
+	return &CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams{
+
+		timeout: cr.DefaultTimeout,
+	}
+}
+
+// NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParamsWithTimeout creates a new CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams object
+// with the default values initialized, and the ability to set a timeout on a request
+func NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParamsWithTimeout(timeout time.Duration) *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams {
+
+	return &CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams{
+
+		timeout: timeout,
+	}
+}
+
+// NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParamsWithContext creates a new CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams object
+// with the default values initialized, and the ability to set a context for a request
+func NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParamsWithContext(ctx context.Context) *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams {
+
+	return &CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams{
+
+		Context: ctx,
+	}
+}
+
+// NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParamsWithHTTPClient creates a new CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams object
+// with the default values initialized, and the ability to set a custom HTTPClient for a request
+func NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParamsWithHTTPClient(client *http.Client) *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams {
+
+	return &CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams{
+		HTTPClient: client,
+	}
+}
+
+/*CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams contains all the parameters to send to the API endpoint
+for the complete self service browser settings o ID c settings flow operation typically these are written to a http.Request
+*/
+type CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams struct {
+	timeout    time.Duration
+	Context    context.Context
+	HTTPClient *http.Client
+}
+
+// WithTimeout adds the timeout to the complete self service browser settings o ID c settings flow params
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) WithTimeout(timeout time.Duration) *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams {
+	o.SetTimeout(timeout)
+	return o
+}
+
+// SetTimeout adds the timeout to the complete self service browser settings o ID c settings flow params
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) SetTimeout(timeout time.Duration) {
+	o.timeout = timeout
+}
+
+// WithContext adds the context to the complete self service browser settings o ID c settings flow params
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) WithContext(ctx context.Context) *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams {
+	o.SetContext(ctx)
+	return o
+}
+
+// SetContext adds the context to the complete self service browser settings o ID c settings flow params
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) SetContext(ctx context.Context) {
+	o.Context = ctx
+}
+
+// WithHTTPClient adds the HTTPClient to the complete self service browser settings o ID c settings flow params
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) WithHTTPClient(client *http.Client) *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams {
+	o.SetHTTPClient(client)
+	return o
+}
+
+// SetHTTPClient adds the HTTPClient to the complete self service browser settings o ID c settings flow params
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) SetHTTPClient(client *http.Client) {
+	o.HTTPClient = client
+}
+
+// WriteToRequest writes these params to a swagger request
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error {
+
+	if err := r.SetTimeout(o.timeout); err != nil {
+		return err
+	}
+	var res []error
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/internal/httpclient/client/public/complete_self_service_browser_settings_o_id_c_settings_flow_responses.go b/internal/httpclient/client/public/complete_self_service_browser_settings_o_id_c_settings_flow_responses.go
new file mode 100644
index 000000000..ddebc8a6c
--- /dev/null
+++ b/internal/httpclient/client/public/complete_self_service_browser_settings_o_id_c_settings_flow_responses.go
@@ -0,0 +1,97 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package public
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/go-openapi/runtime"
+	"github.com/go-openapi/strfmt"
+
+	"github.com/ory/kratos/internal/httpclient/models"
+)
+
+// CompleteSelfServiceBrowserSettingsOIDCSettingsFlowReader is a Reader for the CompleteSelfServiceBrowserSettingsOIDCSettingsFlow structure.
+type CompleteSelfServiceBrowserSettingsOIDCSettingsFlowReader struct {
+	formats strfmt.Registry
+}
+
+// ReadResponse reads a server response into the received o.
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+	switch response.Code() {
+	case 302:
+		result := NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+	case 500:
+		result := NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError()
+		if err := result.readResponse(response, consumer, o.formats); err != nil {
+			return nil, err
+		}
+		return nil, result
+
+	default:
+		return nil, runtime.NewAPIError("unknown error", response, response.Code())
+	}
+}
+
+// NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound creates a CompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound with default headers values
+func NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound() *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound {
+	return &CompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound{}
+}
+
+/*CompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound handles this case with default header values.
+
+Empty responses are sent when, for example, resources are deleted. The HTTP status code for empty responses is
+typically 201.
+*/
+type CompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound struct {
+}
+
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound) Error() string {
+	return fmt.Sprintf("[POST /self-service/browser/flows/registration/strategies/oidc/settings/connections][%d] completeSelfServiceBrowserSettingsOIdCSettingsFlowFound ", 302)
+}
+
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	return nil
+}
+
+// NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError creates a CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError with default headers values
+func NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError() *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError {
+	return &CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError{}
+}
+
+/*CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError handles this case with default header values.
+
+genericError
+*/
+type CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError struct {
+	Payload *models.GenericError
+}
+
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError) Error() string {
+	return fmt.Sprintf("[POST /self-service/browser/flows/registration/strategies/oidc/settings/connections][%d] completeSelfServiceBrowserSettingsOIdCSettingsFlowInternalServerError  %+v", 500, o.Payload)
+}
+
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError) GetPayload() *models.GenericError {
+	return o.Payload
+}
+
+func (o *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error {
+
+	o.Payload = new(models.GenericError)
+
+	// response payload
+	if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/httpclient/client/public/public_client.go b/internal/httpclient/client/public/public_client.go
index f9390e054..78fb2262c 100644
--- a/internal/httpclient/client/public/public_client.go
+++ b/internal/httpclient/client/public/public_client.go
@@ -27,6 +27,8 @@ type Client struct {
 
 // ClientService is the interface for Client methods
 type ClientService interface {
+	CompleteSelfServiceBrowserSettingsOIDCSettingsFlow(params *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) error
+
 	CompleteSelfServiceBrowserSettingsPasswordStrategyFlow(params *CompleteSelfServiceBrowserSettingsPasswordStrategyFlowParams) error
 
 	CompleteSelfServiceBrowserSettingsProfileStrategyFlow(params *CompleteSelfServiceBrowserSettingsProfileStrategyFlowParams) error
@@ -50,6 +52,40 @@ type ClientService interface {
 	SetTransport(transport runtime.ClientTransport)
 }
 
+/*
+  CompleteSelfServiceBrowserSettingsOIDCSettingsFlow completes the browser based settings flow for the open ID connect strategy
+
+  This endpoint completes a browser-based settings flow. This is usually achieved by POSTing data to this
+endpoint.
+
+> This endpoint is NOT INTENDED for API clients and only works with browsers (Chrome, Firefox, ...) and HTML Forms.
+
+More information can be found at [ORY Kratos User Settings & Profile Management Documentation](https://www.ory.sh/docs/next/kratos/self-service/flows/user-settings-profile-management).
+*/
+func (a *Client) CompleteSelfServiceBrowserSettingsOIDCSettingsFlow(params *CompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams) error {
+	// TODO: Validate the params before sending
+	if params == nil {
+		params = NewCompleteSelfServiceBrowserSettingsOIDCSettingsFlowParams()
+	}
+
+	_, err := a.transport.Submit(&runtime.ClientOperation{
+		ID:                 "completeSelfServiceBrowserSettingsOIDCSettingsFlow",
+		Method:             "POST",
+		PathPattern:        "/self-service/browser/flows/registration/strategies/oidc/settings/connections",
+		ProducesMediaTypes: []string{"application/json"},
+		ConsumesMediaTypes: []string{"application/json", "application/x-www-form-urlencoded"},
+		Schemes:            []string{"http", "https"},
+		Params:             params,
+		Reader:             &CompleteSelfServiceBrowserSettingsOIDCSettingsFlowReader{formats: a.formats},
+		Context:            params.Context,
+		Client:             params.HTTPClient,
+	})
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
 /*
   CompleteSelfServiceBrowserSettingsPasswordStrategyFlow completes the browser based settings flow for the password strategy
 
diff --git a/internal/httpclient/models/errors.go b/internal/httpclient/models/errors.go
new file mode 100644
index 000000000..beb397331
--- /dev/null
+++ b/internal/httpclient/models/errors.go
@@ -0,0 +1,45 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package models
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"strconv"
+
+	"github.com/go-openapi/errors"
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// Errors errors
+//
+// swagger:model Errors
+type Errors []*Error
+
+// Validate validates this errors
+func (m Errors) Validate(formats strfmt.Registry) error {
+	var res []error
+
+	for i := 0; i < len(m); i++ {
+		if swag.IsZero(m[i]) { // not required
+			continue
+		}
+
+		if m[i] != nil {
+			if err := m[i].Validate(formats); err != nil {
+				if ve, ok := err.(*errors.Validation); ok {
+					return ve.ValidateName(strconv.Itoa(i))
+				}
+				return err
+			}
+		}
+
+	}
+
+	if len(res) > 0 {
+		return errors.CompositeValidationError(res...)
+	}
+	return nil
+}
diff --git a/internal/httpclient/models/form_field.go b/internal/httpclient/models/form_field.go
index 38eae4c32..a5ea8f96e 100644
--- a/internal/httpclient/models/form_field.go
+++ b/internal/httpclient/models/form_field.go
@@ -6,8 +6,6 @@ package models
 // Editing this file might prove futile when you re-run the swagger generate command
 
 import (
-	"strconv"
-
 	"github.com/go-openapi/errors"
 	"github.com/go-openapi/strfmt"
 	"github.com/go-openapi/swag"
@@ -22,8 +20,8 @@ type FormField struct {
 	// Disabled is the equivalent of `<input disabled="{{.Disabled}}">`
 	Disabled bool `json:"disabled,omitempty"`
 
-	// Errors contains all validation errors this particular field has caused.
-	Errors []*Error `json:"errors"`
+	// errors
+	Errors Errors `json:"errors,omitempty"`
 
 	// Name is the equivalent of `<input name="{{.Name}}">`
 	// Required: true
@@ -33,8 +31,7 @@ type FormField struct {
 	Pattern string `json:"pattern,omitempty"`
 
 	// Required is the equivalent of `<input required="{{.Required}}">`
-	// Required: true
-	Required *bool `json:"required"`
+	Required bool `json:"required,omitempty"`
 
 	// Type is the equivalent of `<input type="{{.Type}}">`
 	// Required: true
@@ -56,10 +53,6 @@ func (m *FormField) Validate(formats strfmt.Registry) error {
 		res = append(res, err)
 	}
 
-	if err := m.validateRequired(formats); err != nil {
-		res = append(res, err)
-	}
-
 	if err := m.validateType(formats); err != nil {
 		res = append(res, err)
 	}
@@ -76,20 +69,11 @@ func (m *FormField) validateErrors(formats strfmt.Registry) error {
 		return nil
 	}
 
-	for i := 0; i < len(m.Errors); i++ {
-		if swag.IsZero(m.Errors[i]) { // not required
-			continue
-		}
-
-		if m.Errors[i] != nil {
-			if err := m.Errors[i].Validate(formats); err != nil {
-				if ve, ok := err.(*errors.Validation); ok {
-					return ve.ValidateName("errors" + "." + strconv.Itoa(i))
-				}
-				return err
-			}
+	if err := m.Errors.Validate(formats); err != nil {
+		if ve, ok := err.(*errors.Validation); ok {
+			return ve.ValidateName("errors")
 		}
-
+		return err
 	}
 
 	return nil
@@ -104,15 +88,6 @@ func (m *FormField) validateName(formats strfmt.Registry) error {
 	return nil
 }
 
-func (m *FormField) validateRequired(formats strfmt.Registry) error {
-
-	if err := validate.Required("required", "body", m.Required); err != nil {
-		return err
-	}
-
-	return nil
-}
-
 func (m *FormField) validateType(formats strfmt.Registry) error {
 
 	if err := validate.Required("type", "body", m.Type); err != nil {
diff --git a/internal/httpclient/models/provider_credentials_config.go b/internal/httpclient/models/provider_credentials_config.go
new file mode 100644
index 000000000..ef0b1e6d4
--- /dev/null
+++ b/internal/httpclient/models/provider_credentials_config.go
@@ -0,0 +1,46 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package models
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// ProviderCredentialsConfig provider credentials config
+//
+// swagger:model ProviderCredentialsConfig
+type ProviderCredentialsConfig struct {
+
+	// provider
+	Provider string `json:"provider,omitempty"`
+
+	// subject
+	Subject string `json:"subject,omitempty"`
+}
+
+// Validate validates this provider credentials config
+func (m *ProviderCredentialsConfig) Validate(formats strfmt.Registry) error {
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *ProviderCredentialsConfig) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *ProviderCredentialsConfig) UnmarshalBinary(b []byte) error {
+	var res ProviderCredentialsConfig
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}
diff --git a/package.json b/package.json
index a6ad8ea60..5bf9bbe20 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "private": true,
   "scripts": {
-    "format": "prettier --no-semi --single-quote --trailing-comma es5 --write \"cypress/**/*.js\"",
+    "format": "prettier --no-semi --single-quote --trailing-comma es5 --write \"test/e2e/cypress/**/*.js\"",
     "test": "cypress run",
     "test:watch": "cypress open",
     "wait-on": "wait-on"
diff --git a/persistence/sql/persister_profile.go b/persistence/sql/persister_profile.go
index c940cae49..cf916e5d4 100644
--- a/persistence/sql/persister_profile.go
+++ b/persistence/sql/persister_profile.go
@@ -6,8 +6,9 @@ import (
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gofrs/uuid"
 
-	"github.com/ory/kratos/selfservice/flow/settings"
 	"github.com/ory/x/sqlcon"
+
+	"github.com/ory/kratos/selfservice/flow/settings"
 )
 
 var _ settings.RequestPersister = new(Persister)
@@ -39,13 +40,17 @@ func (p *Persister) UpdateSettingsRequest(ctx context.Context, r *settings.Reque
 		}
 
 		for id, form := range r.Methods {
+			var found bool
 			for oid := range rr.Methods {
 				if oid == id {
 					rr.Methods[id].Config = form.Config
+					found = true
 					break
 				}
 			}
-			rr.Methods[id] = form
+			if !found {
+				rr.Methods[id] = form
+			}
 		}
 
 		for _, of := range rr.Methods {
diff --git a/selfservice/flow/login/request.go b/selfservice/flow/login/request.go
index 9b245c123..34d8b43f7 100644
--- a/selfservice/flow/login/request.go
+++ b/selfservice/flow/login/request.go
@@ -127,7 +127,6 @@ func (r *Request) Valid() error {
 	if r.ExpiresAt.Before(time.Now()) {
 		return errors.WithStack(newRequestExpiredError(time.Since(r.ExpiresAt)))
 	}
-
 	return nil
 }
 
diff --git a/selfservice/flow/registration/request.go b/selfservice/flow/registration/request.go
index 2bd2fbfa5..58b2c2cc6 100644
--- a/selfservice/flow/registration/request.go
+++ b/selfservice/flow/registration/request.go
@@ -119,10 +119,6 @@ func (r *Request) GetID() uuid.UUID {
 	return r.ID
 }
 
-func (r *Request) IsForced() bool {
-	return false
-}
-
 func (r *Request) Valid() error {
 	if r.ExpiresAt.Before(time.Now()) {
 		return errors.WithStack(newRequestExpiredError(time.Since(r.ExpiresAt)))
diff --git a/selfservice/flow/settings/handler.go b/selfservice/flow/settings/handler.go
index 81912caa6..dbe4988f2 100644
--- a/selfservice/flow/settings/handler.go
+++ b/selfservice/flow/settings/handler.go
@@ -101,7 +101,7 @@ func (h *Handler) initUpdateSettings(w http.ResponseWriter, r *http.Request, ps
 
 	a := NewRequest(h.c.SelfServiceSettingsRequestLifespan(), r, s)
 	for _, strategy := range h.d.SettingsStrategies() {
-		if err := h.d.ContinuityManager().Abort(r.Context(), w, r, "settings_"+strategy.SettingsStrategyID()); err != nil {
+		if err := h.d.ContinuityManager().Abort(r.Context(), w, r, ContinuityKey(strategy.SettingsStrategyID())); err != nil {
 			h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
 			return
 		}
@@ -197,7 +197,7 @@ func (h *Handler) fetchUpdateSettingsRequest(w http.ResponseWriter, r *http.Requ
 		}
 	}
 
-	if pr.ExpiresAt.Before(time.Now()) {
+	if pr.ExpiresAt.Before(time.Now().UTC()) {
 		return errors.WithStack(x.ErrGone.
 			WithReason("The settings request has expired. Redirect the user to the login endpoint to initialize a new session.").
 			WithDetail("redirect_to", urlx.AppendPaths(h.c.SelfPublicURL(), PublicPath).String()))
diff --git a/selfservice/flow/settings/hook.go b/selfservice/flow/settings/hook.go
index 6d29a9161..0f5c40e6b 100644
--- a/selfservice/flow/settings/hook.go
+++ b/selfservice/flow/settings/hook.go
@@ -1,6 +1,7 @@
 package settings
 
 import (
+	"fmt"
 	"net/http"
 	"net/url"
 	"time"
@@ -11,7 +12,6 @@ import (
 
 	"github.com/ory/kratos/driver/configuration"
 	"github.com/ory/kratos/identity"
-	"github.com/ory/kratos/session"
 	"github.com/ory/kratos/x"
 )
 
@@ -68,13 +68,30 @@ func NewHookExecutor(
 	}
 }
 
-func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request, settingsType string, a *Request, ss *session.Session, i *identity.Identity) error {
+type PostSettingsHookOption func(o *postSettingsHookOptions)
+
+type postSettingsHookOptions struct {
+	cb func(ctxUpdate *UpdateContext) error
+}
+
+func WithCallback(cb func(ctxUpdate *UpdateContext) error) func(o *postSettingsHookOptions) {
+	return func(o *postSettingsHookOptions) {
+		o.cb = cb
+	}
+}
+
+func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request, settingsType string, ctxUpdate *UpdateContext, i *identity.Identity, opts ...PostSettingsHookOption) error {
+	config := new(postSettingsHookOptions)
+	for _, f := range opts {
+		f(config)
+	}
+
 	e.d.Logger().
 		WithField("identity_id", i.ID).
 		Debug("An identity's settings have been updated, running post hooks.")
 
 	for _, executor := range e.d.PostSettingsPrePersistHooks(settingsType) {
-		if err := executor.ExecuteSettingsPrePersistHook(w, r, a, i); err != nil {
+		if err := executor.ExecuteSettingsPrePersistHook(w, r, ctxUpdate.Request, i); err != nil {
 			if errors.Is(err, ErrHookAbortRequest) {
 				return nil
 			}
@@ -83,25 +100,34 @@ func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request,
 	}
 
 	options := []identity.ManagerOption{identity.ManagerExposeValidationErrors}
-	if ss.AuthenticatedAt.Add(e.c.SelfServicePrivilegedSessionMaxAge()).After(time.Now()) {
+	ttl := e.c.SelfServicePrivilegedSessionMaxAge()
+	if ctxUpdate.Session.AuthenticatedAt.Add(ttl).After(time.Now()) {
 		options = append(options, identity.ManagerAllowWriteProtectedTraits)
 	}
 
 	if err := e.d.IdentityManager().Update(r.Context(), i, options...); err != nil {
 		if errors.Is(err, identity.ErrProtectedFieldModified) {
+			e.d.Logger().WithField("error", fmt.Sprintf("%+v", err)).Debug("Modifying protected field requires re-authentication.")
 			return errors.WithStack(ErrRequestNeedsReAuthentication)
 		}
 		return err
 	}
 
-	ss.Identity = i
-	a.UpdateSuccessful = true
-	if err := e.d.SettingsRequestPersister().UpdateSettingsRequest(r.Context(), a); err != nil {
+	ctxUpdate.Session.Identity = i
+	ctxUpdate.Request.UpdateSuccessful = true
+
+	if config.cb != nil {
+		if err := config.cb(ctxUpdate); err != nil {
+			return err
+		}
+	}
+
+	if err := e.d.SettingsRequestPersister().UpdateSettingsRequest(r.Context(), ctxUpdate.Request); err != nil {
 		return err
 	}
 
 	for _, executor := range e.d.PostSettingsPostPersistHooks(settingsType) {
-		if err := executor.ExecuteSettingsPostPersistHook(w, r, a, i); err != nil {
+		if err := executor.ExecuteSettingsPostPersistHook(w, r, ctxUpdate.Request, i); err != nil {
 			if errors.Is(err, ErrHookAbortRequest) {
 				return nil
 			}
@@ -113,10 +139,10 @@ func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request,
 		WithField("identity_id", i.ID).
 		Debug("Post settings execution hooks completed successfully.")
 
-	return x.SecureContentNegotiationRedirection(w, r, ss.Declassify(), a.RequestURL, e.d.Writer(), e.c,
+	return x.SecureContentNegotiationRedirection(w, r, ctxUpdate.Session.Declassify(), ctxUpdate.Request.RequestURL, e.d.Writer(), e.c,
 		x.SecureRedirectOverrideDefaultReturnTo(
 			e.c.SelfServiceSettingsReturnTo(settingsType,
 				urlx.CopyWithQuery(
 					e.c.SettingsURL(),
-					url.Values{"request": {a.ID.String()}}))))
+					url.Values{"request": {ctxUpdate.Request.ID.String()}}))))
 }
diff --git a/selfservice/flow/settings/persistence.go b/selfservice/flow/settings/persistence.go
index d82b1c39b..b77b17e07 100644
--- a/selfservice/flow/settings/persistence.go
+++ b/selfservice/flow/settings/persistence.go
@@ -101,10 +101,18 @@ func TestRequestPersister(p interface {
 
 		t.Run("case=should create and update a settings request", func(t *testing.T) {
 			expected := newRequest(t)
+			expected.Methods["oidc"] = &RequestMethod{
+				Method: "oidc", Config: &RequestMethodConfig{RequestMethodConfigurator: &form.HTMLForm{Fields: []form.Field{{
+					Name: "zab", Type: "bar", Pattern: "baz"}}}}}
+			expected.Methods["password"] = &RequestMethod{
+				Method: "password", Config: &RequestMethodConfig{RequestMethodConfigurator: &form.HTMLForm{Fields: []form.Field{{
+					Name: "foo", Type: "bar", Pattern: "baz"}}}}}
 			err := p.CreateSettingsRequest(context.Background(), expected)
 			require.NoError(t, err)
 
 			expected.Methods[StrategyProfile].Config.RequestMethodConfigurator.(*form.HTMLForm).Action = "/new-action"
+			expected.Methods["password"].Config.RequestMethodConfigurator.(*form.HTMLForm).Fields = []form.Field{{
+				Name: "zab", Type: "zab", Pattern: "zab"}}
 			expected.RequestURL = "/new-request-url"
 			require.NoError(t, p.UpdateSettingsRequest(context.Background(), expected))
 
@@ -113,6 +121,10 @@ func TestRequestPersister(p interface {
 
 			assert.Equal(t, "/new-action", actual.Methods[StrategyProfile].Config.RequestMethodConfigurator.(*form.HTMLForm).Action)
 			assert.Equal(t, "/new-request-url", actual.RequestURL)
+			assert.EqualValues(t, []form.Field{{Name: "zab", Type: "zab", Pattern: "zab"}}, actual.
+				Methods["password"].Config.RequestMethodConfigurator.(*form.HTMLForm).Fields)
+			assert.EqualValues(t, []form.Field{{Name: "zab", Type: "bar", Pattern: "baz"}}, actual.
+				Methods["oidc"].Config.RequestMethodConfigurator.(*form.HTMLForm).Fields)
 		})
 	}
 }
diff --git a/selfservice/flow/settings/request.go b/selfservice/flow/settings/request.go
index b6fbaf68b..56e61512f 100644
--- a/selfservice/flow/settings/request.go
+++ b/selfservice/flow/settings/request.go
@@ -100,12 +100,19 @@ func (r *Request) TableName() string {
 	return "selfservice_settings_requests"
 }
 
+func (r *Request) GetID() uuid.UUID {
+	return r.ID
+}
+
 func (r *Request) Valid(s *session.Session) error {
-	if r.ExpiresAt.Before(time.Now()) {
-		return errors.WithStack(ErrRequestExpired.WithReasonf("The settings request expired %.2f minutes ago, please try again.", time.Since(r.ExpiresAt).Minutes()))
+	if r.ExpiresAt.Before(time.Now().UTC()) {
+		return errors.WithStack(ErrRequestExpired.
+			WithReasonf("The settings request expired %.2f minutes ago, please try again.",
+				-time.Since(r.ExpiresAt).Minutes()))
 	}
 	if r.IdentityID != s.Identity.ID {
-		return errors.WithStack(herodot.ErrBadRequest.WithReasonf("The settings request expired %.2f minutes ago, please try again", time.Since(r.ExpiresAt).Minutes()))
+		return errors.WithStack(herodot.ErrBadRequest.WithReasonf(
+			"You must restart the flow because the resumable session was initiated by another person."))
 	}
 	return nil
 }
diff --git a/selfservice/flow/settings/strategy_helper.go b/selfservice/flow/settings/strategy_helper.go
new file mode 100644
index 000000000..0bcdd5097
--- /dev/null
+++ b/selfservice/flow/settings/strategy_helper.go
@@ -0,0 +1,107 @@
+package settings
+
+import (
+	"fmt"
+	"net/http"
+	"runtime/debug"
+	"time"
+
+	"github.com/gofrs/uuid"
+	"github.com/pkg/errors"
+
+	"github.com/ory/x/sqlcon"
+
+	"github.com/ory/herodot"
+
+	"github.com/ory/kratos/continuity"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/session"
+	"github.com/ory/kratos/x"
+)
+
+var (
+	ErrContinuePreviousAction = errors.New("found prior action")
+)
+
+type UpdatePayload interface {
+	GetRequestID() uuid.UUID
+	SetRequestID(id uuid.UUID)
+}
+
+type UpdateContext struct {
+	Valid   bool
+	Session *session.Session
+	Request *Request
+}
+
+func PrepareUpdate(d interface {
+	x.LoggingProvider
+	continuity.ManagementProvider
+	session.ManagementProvider
+	RequestPersistenceProvider
+}, w http.ResponseWriter, r *http.Request, name string, payload UpdatePayload) (*UpdateContext, error) {
+	ss, err := d.SessionManager().FetchFromRequest(r.Context(), r)
+	if err != nil {
+		return new(UpdateContext), err
+	}
+
+	rid, err := GetRequestID(r)
+	if err != nil {
+		return new(UpdateContext), err
+	}
+
+	payload.SetRequestID(rid)
+
+	req, err := d.SettingsRequestPersister().GetSettingsRequest(r.Context(), rid)
+	if errors.Is(err, sqlcon.ErrNoRows) {
+		return new(UpdateContext), errors.WithStack(herodot.ErrNotFound.WithReasonf("The settings request could not be found. Please restart the flow."))
+	} else if err != nil {
+		return new(UpdateContext), err
+	}
+
+	if err := req.Valid(ss); err != nil {
+		return new(UpdateContext), err
+	}
+
+	c := &UpdateContext{Session: ss, Request: req, Valid: true}
+	if _, err := d.ContinuityManager().Continue(
+		r.Context(), w, r, name,
+		ContinuityOptions(payload, ss.Identity)...); err == nil {
+		c.Valid = true
+		if payload.GetRequestID() == rid {
+			return c, ErrContinuePreviousAction
+		}
+		d.Logger().
+			WithField("package", pkgName).
+			WithField("stack_trace", fmt.Sprintf("%s", debug.Stack())).
+			WithField("expected_request_id", payload.GetRequestID()).
+			WithField("actual_request_id", rid).
+			Debug("Request ID from continuity manager does not match Request ID from request.")
+		return c, nil
+	} else if !errors.Is(err, &continuity.ErrNotResumable) {
+		return new(UpdateContext), err
+	}
+
+	if err := r.ParseForm(); err != nil {
+		return new(UpdateContext), errors.WithStack(err)
+	}
+
+	c.Valid = true
+	return c, nil
+}
+
+func ContinuityOptions(p interface{}, i *identity.Identity) []continuity.ManagerOption {
+	return []continuity.ManagerOption{
+		continuity.WithPayload(p),
+		continuity.WithIdentity(i),
+		continuity.WithLifespan(time.Minute * 15),
+	}
+}
+
+func GetRequestID(r *http.Request) (uuid.UUID, error) {
+	rid := x.ParseUUID(r.URL.Query().Get("request"))
+	if rid == uuid.Nil {
+		return rid, errors.WithStack(herodot.ErrBadRequest.WithReasonf("The request query parameter is missing or malformed."))
+	}
+	return rid, nil
+}
diff --git a/selfservice/flow/settings/strategy_profile.go b/selfservice/flow/settings/strategy_profile.go
index 3bbff77b5..cc912e6cf 100644
--- a/selfservice/flow/settings/strategy_profile.go
+++ b/selfservice/flow/settings/strategy_profile.go
@@ -3,10 +3,13 @@ package settings
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/url"
-	"time"
+	"reflect"
+	"runtime/debug"
 
+	"github.com/gofrs/uuid"
 	"github.com/julienschmidt/httprouter"
 	"github.com/pkg/errors"
 	"github.com/tidwall/sjson"
@@ -29,14 +32,16 @@ import (
 const (
 	StrategyProfile           = "profile"
 	PublicSettingsProfilePath = "/self-service/browser/flows/settings/strategies/profile"
+
+	ContinuityPrefix = "ory_kratos_settings"
 )
 
-func strategyProfileContinuityName(rid string) string {
-	// Use one individual container per request ID to prevent resuming other request IDs.
-	return "ory_kratos_settings_profile." + rid
+func ContinuityKey(id string) string {
+	return ContinuityPrefix + "_" + id
 }
 
 var _ Strategy = new(StrategyTraits)
+var pkgName = reflect.TypeOf(StrategyTraits{}).PkgPath()
 
 type (
 	strategyDependencies interface {
@@ -144,89 +149,65 @@ func (s *StrategyTraits) PopulateSettingsMethod(r *http.Request, ss *session.Ses
 //       302: emptyResponse
 //       500: genericError
 func (s *StrategyTraits) handleSubmit(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
-	ss, err := s.d.SessionManager().FetchFromRequest(r.Context(), r)
-	if err != nil {
-		s.handleSettingsError(w, r, nil, nil, nil, nil, err)
-		return
-	}
-
 	var p completeSelfServiceBrowserSettingsStrategyProfileFlowPayload
-	rid := r.URL.Query().Get("request")
-	p = completeSelfServiceBrowserSettingsStrategyProfileFlowPayload{RequestID: rid}
-	if len(rid) == 0 {
-		s.handleSettingsError(w, r, nil, ss, json.RawMessage(ss.Identity.Traits), &p, errors.WithStack(herodot.ErrBadRequest.WithReasonf("The request query parameter is missing.")))
+	ctxUpdate, err := PrepareUpdate(s.d, w, r, ContinuityKey(s.SettingsStrategyID()), &p)
+	s.d.Logger().
+		WithField("err", fmt.Sprintf("%+v", err)).
+		WithField("ctxUpdate", fmt.Sprintf("%+v", ctxUpdate)).
+		WithField("package", pkgName).
+		WithField("stack_trace", fmt.Sprintf("%s", debug.Stack())).
+		Debug("handleSubmit: PrepareUpdate")
+	if errors.Is(err, ErrContinuePreviousAction) {
+		s.continueFlow(w, r, ctxUpdate, &p)
 		return
-	}
-
-	if _, err = s.d.ContinuityManager().Continue(r.Context(), r,
-		strategyProfileContinuityName(rid),
-		continuity.WithIdentity(ss.Identity),
-		continuity.WithPayload(&p),
-	); err == nil {
-		if p.RequestID == r.URL.Query().Get("request") {
-			s.continueFlow(w, r, ss, &p)
-			return
-		}
-	} else if !errors.Is(err, &continuity.ErrNotResumable) {
-		s.handleSettingsError(w, r, nil, ss, json.RawMessage(ss.Identity.Traits), &p, err)
+	} else if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, nil, &p, err)
 		return
 	}
 
-	option, err := s.newSettingsProfileDecoder(ss.Identity)
+	option, err := s.newSettingsProfileDecoder(ctxUpdate.Session.Identity)
 	if err != nil {
-		s.handleSettingsError(w, r, nil, ss, json.RawMessage(ss.Identity.Traits), &p, err)
+		s.handleSettingsError(w, r, ctxUpdate, nil, &p, err)
 		return
 	}
 
-	p = completeSelfServiceBrowserSettingsStrategyProfileFlowPayload{}
 	if err := decoderx.NewHTTP().Decode(r, &p,
 		decoderx.HTTPFormDecoder(),
 		option,
 		decoderx.HTTPDecoderSetValidatePayloads(false),
 		decoderx.HTTPDecoderSetIgnoreParseErrorsStrategy(decoderx.ParseErrorIgnore),
 	); err != nil {
-		s.handleSettingsError(w, r, nil, ss, json.RawMessage(ss.Identity.Traits), &p, err)
+		s.handleSettingsError(w, r, ctxUpdate, nil, &p, err)
 		return
 	}
 
-	p.RequestID = rid
-	s.continueFlow(w, r, ss, &p)
-}
-
-func (s *StrategyTraits) continueFlow(w http.ResponseWriter, r *http.Request, ss *session.Session, p *completeSelfServiceBrowserSettingsStrategyProfileFlowPayload) {
-	ar, err := s.d.SettingsRequestPersister().GetSettingsRequest(r.Context(), x.ParseUUID(p.RequestID))
-	if err != nil {
-		s.handleSettingsError(w, r, nil, ss, json.RawMessage(ss.Identity.Traits), p, err)
-		return
-	}
+	// Reset after decoding form
+	p.SetRequestID(ctxUpdate.Request.ID)
 
-	if err := ar.Valid(ss); err != nil {
-		s.handleSettingsError(w, r, ar, ss, json.RawMessage(ss.Identity.Traits), p, err)
-		return
-	}
+	s.continueFlow(w, r, ctxUpdate, &p)
+}
 
+func (s *StrategyTraits) continueFlow(w http.ResponseWriter, r *http.Request, ctxUpdate *UpdateContext, p *completeSelfServiceBrowserSettingsStrategyProfileFlowPayload) {
 	if len(p.Traits) == 0 {
-		s.handleSettingsError(w, r, ar, ss, json.RawMessage(ss.Identity.Traits), p, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Did not receive any value changes.")))
+		s.handleSettingsError(w, r, ctxUpdate, nil, p, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Did not receive any value changes.")))
 		return
 	}
 
-	if err := s.hydrateForm(r, ar, ss, p.Traits); err != nil {
-		s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ar, err, StrategyProfile)
+	if err := s.hydrateForm(r, ctxUpdate.Request, ctxUpdate.Session, p.Traits); err != nil {
+		s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ctxUpdate.Request, err, StrategyProfile)
 		return
 	}
 
-	update, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), ss.Identity.ID)
+	update, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), ctxUpdate.Session.Identity.ID)
 	if err != nil {
-		s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ar, err, StrategyProfile)
+		s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ctxUpdate.Request, err, StrategyProfile)
 		return
 	}
-	update.Traits = identity.Traits(p.Traits)
 
+	update.Traits = identity.Traits(p.Traits)
 	if err := s.d.SettingsHookExecutor().PostSettingsHook(w, r,
-		StrategyProfile,
-		ar, ss, update,
-	); err != nil {
-		s.handleSettingsError(w, r, ar, ss, p.Traits, p, err)
+		StrategyProfile, ctxUpdate, update); err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p.Traits, p, err)
 		return
 	}
 }
@@ -262,6 +243,16 @@ type completeSelfServiceBrowserSettingsStrategyProfileFlowPayload struct {
 	//
 	// in: query
 	RequestID string `json:"request_id"`
+
+	rid uuid.UUID
+}
+
+func (p *completeSelfServiceBrowserSettingsStrategyProfileFlowPayload) GetRequestID() uuid.UUID {
+	return p.rid
+}
+
+func (p *completeSelfServiceBrowserSettingsStrategyProfileFlowPayload) SetRequestID(rid uuid.UUID) {
+	p.rid = rid
 }
 
 func (s *StrategyTraits) hydrateForm(r *http.Request, ar *Request, ss *session.Session, traits json.RawMessage) error {
@@ -292,28 +283,35 @@ func (s *StrategyTraits) hydrateForm(r *http.Request, ar *Request, ss *session.S
 
 // handleSettingsError is a convenience function for handling all types of errors that may occur (e.g. validation error)
 // during a settings request.
-func (s *StrategyTraits) handleSettingsError(w http.ResponseWriter, r *http.Request, rr *Request, ss *session.Session, traits json.RawMessage, p *completeSelfServiceBrowserSettingsStrategyProfileFlowPayload, err error) {
+func (s *StrategyTraits) handleSettingsError(w http.ResponseWriter, r *http.Request, puc *UpdateContext, traits json.RawMessage, p *completeSelfServiceBrowserSettingsStrategyProfileFlowPayload, err error) {
 	if errors.Is(err, ErrRequestNeedsReAuthentication) {
-		if err := s.d.ContinuityManager().Pause(
-			r.Context(), w, r,
-			strategyProfileContinuityName(r.URL.Query().Get("request")),
-			continuity.WithPayload(p),
-			continuity.WithIdentity(ss.Identity),
-			continuity.WithLifespan(time.Minute*15),
-		); err != nil {
-			s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, rr, err, s.SettingsStrategyID())
+		s.d.Logger().
+			WithField("err", fmt.Sprintf("%+v", err)).
+			WithField("key", ContinuityKey(s.SettingsStrategyID())).
+			WithField("package", pkgName).
+			WithField("stack_trace", fmt.Sprintf("%s", debug.Stack())).
+			Debug("handleSettingsError: Pause")
+
+		if err := s.d.ContinuityManager().Pause(r.Context(), w, r,
+			ContinuityKey(s.SettingsStrategyID()),
+			ContinuityOptions(p, puc.Session.Identity)...); err != nil {
+			s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, puc.Request, err, s.SettingsStrategyID())
 			return
 		}
 	}
 
-	if rr != nil {
-		if err := s.hydrateForm(r, rr, ss, traits); err != nil {
-			s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, rr, err, s.SettingsStrategyID())
+	if puc.Request != nil {
+		if traits == nil {
+			traits = json.RawMessage(puc.Session.Identity.Traits)
+		}
+
+		if err := s.hydrateForm(r, puc.Request, puc.Session, traits); err != nil {
+			s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, puc.Request, err, s.SettingsStrategyID())
 			return
 		}
 	}
 
-	s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, rr, err, s.SettingsStrategyID())
+	s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, puc.Request, err, s.SettingsStrategyID())
 }
 
 // newSettingsProfileDecoder returns a decoderx.HTTPDecoderOption with a JSON Schema for type assertion and
diff --git a/selfservice/form/error.go b/selfservice/form/error.go
index 28b99aa1a..654804530 100644
--- a/selfservice/form/error.go
+++ b/selfservice/form/error.go
@@ -6,6 +6,9 @@ type (
 		Reason() string
 	}
 
+	// swagger:model formErrors
+	Errors []Error
+
 	// swagger:model formError
 	Error struct {
 		// Code    FormErrorCode `json:"id,omitempty"`
diff --git a/selfservice/form/fields.go b/selfservice/form/fields.go
index b9b120653..1f1edf1a1 100644
--- a/selfservice/form/fields.go
+++ b/selfservice/form/fields.go
@@ -38,15 +38,13 @@ type Field struct {
 	Disabled bool `json:"disabled,omitempty"`
 
 	// Required is the equivalent of `<input required="{{.Required}}">`
-	//
-	// required: true
-	Required bool `json:"required"`
+	Required bool `json:"required,omitempty"`
 
 	// Value is the equivalent of `<input value="{{.Value}}">`
 	Value interface{} `json:"value,omitempty" faker:"name"`
 
 	// Errors contains all validation errors this particular field has caused.
-	Errors []Error `json:"errors,omitempty"`
+	Errors Errors `json:"errors,omitempty"`
 }
 
 // Reset resets a field's value and errors.
diff --git a/selfservice/strategy/oidc/const.go b/selfservice/strategy/oidc/const.go
index eb67ae04b..d2ef3944a 100644
--- a/selfservice/strategy/oidc/const.go
+++ b/selfservice/strategy/oidc/const.go
@@ -1,8 +1,5 @@
 package oidc
 
 const (
-	sessionName      = "oidc_session"
-	sessionRequestID = "request_id"
-	sessionKeyState  = "state"
-	sessionFormState = "form"
+	sessionName = "ory_kratos_oidc_auth_code_session"
 )
diff --git a/selfservice/strategy/oidc/provider_generic_oidc.go b/selfservice/strategy/oidc/provider_generic_oidc.go
index f26d17420..1bb596f4c 100644
--- a/selfservice/strategy/oidc/provider_generic_oidc.go
+++ b/selfservice/strategy/oidc/provider_generic_oidc.go
@@ -69,7 +69,7 @@ func (g *ProviderGenericOIDC) OAuth2(ctx context.Context) (*oauth2.Config, error
 }
 
 func (g *ProviderGenericOIDC) AuthCodeURLOptions(r request) []oauth2.AuthCodeOption {
-	if r.IsForced() {
+	if isForced(r) {
 		return []oauth2.AuthCodeOption{
 			oauth2.SetAuthURLParam("prompt", "login"),
 		}
diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go
index 3e65711df..36606e428 100644
--- a/selfservice/strategy/oidc/strategy.go
+++ b/selfservice/strategy/oidc/strategy.go
@@ -8,9 +8,9 @@ import (
 	"net/http"
 	"net/url"
 	"strings"
+	"time"
 
 	"github.com/gofrs/uuid"
-	"github.com/google/go-jsonnet"
 	"github.com/julienschmidt/httprouter"
 	"github.com/pkg/errors"
 	"github.com/tidwall/gjson"
@@ -22,8 +22,10 @@ import (
 	"github.com/ory/herodot"
 	"github.com/ory/x/urlx"
 
+	"github.com/ory/kratos/continuity"
 	"github.com/ory/kratos/selfservice/flow/login"
 	"github.com/ory/kratos/selfservice/flow/registration"
+	"github.com/ory/kratos/selfservice/flow/settings"
 	"github.com/ory/kratos/selfservice/form"
 
 	"github.com/ory/kratos/driver/configuration"
@@ -35,23 +37,13 @@ import (
 )
 
 const (
-	BasePath = "/self-service/browser/flows/registration/strategies/oidc"
+	BasePath = "/self-service/browser/flows/strategies/oidc"
 
 	AuthPath     = BasePath + "/auth/:request"
 	CallbackPath = BasePath + "/callback/:provider"
-
-	registrationFormPayloadSchema = `{
-  "$id": "https://schemas.ory.sh/kratos/selfservice/oidc/registration/config.schema.json",
-  "$schema": "http://json-schema.org/draft-07/schema#",
-  "type": "object",
-  "properties": {
-    "traits": {}
-  }
-}`
 )
 
-var _ login.Strategy = new(Strategy)
-var _ registration.Strategy = new(Strategy)
+var _ identity.ActiveCredentialsCounter = new(Strategy)
 
 type dependencies interface {
 	errorx.ManagementProvider
@@ -79,6 +71,21 @@ type dependencies interface {
 	registration.StrategyProvider
 	registration.HandlerProvider
 	registration.ErrorHandlerProvider
+
+	settings.ErrorHandlerProvider
+	settings.RequestPersistenceProvider
+	settings.HookExecutorProvider
+
+	continuity.ManagementProvider
+
+	identity.ActiveCredentialsCounterStrategyProvider
+}
+
+func isForced(req interface{}) bool {
+	f, ok := req.(interface {
+		IsForced() bool
+	})
+	return ok && f.IsForced()
 }
 
 // Strategy implements selfservice.LoginStrategy, selfservice.RegistrationStrategy. It supports both login
@@ -90,12 +97,35 @@ type Strategy struct {
 	validator *schema.Validator
 }
 
-func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) {
-	s.setRoutes(r)
+type authCodeContainer struct {
+	RequestID string     `json:"request_id"`
+	State     string     `json:"state"`
+	Form      url.Values `json:"form"`
 }
 
-func (s *Strategy) RegisterRegistrationRoutes(r *x.RouterPublic) {
-	s.setRoutes(r)
+func (s *Strategy) CountActiveCredentials(cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
+	for _, c := range cc {
+		if c.Type == s.ID() && gjson.ValidBytes(c.Config) {
+			var conf CredentialsConfig
+			if err = json.Unmarshal(c.Config, &conf); err != nil {
+				return 0, errors.WithStack(err)
+			}
+
+			for _, ider := range c.Identifiers {
+				parts := strings.Split(ider, ":")
+				if len(parts) != 2 {
+					continue
+				}
+
+				for _, prov := range conf.Providers {
+					if parts[0] == prov.Provider && parts[1] == prov.Subject && len(prov.Subject) > 1 && len(prov.Provider) > 1 {
+						count++
+					}
+				}
+			}
+		}
+	}
+	return
 }
 
 func (s *Strategy) setRoutes(r *x.RouterPublic) {
@@ -130,16 +160,12 @@ func (s *Strategy) ID() identity.CredentialsType {
 
 func (s *Strategy) handleAuth(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	rid := x.ParseUUID(ps.ByName("request"))
-
 	if err := r.ParseForm(); err != nil {
 		s.handleError(w, r, rid, "", nil, errors.WithStack(herodot.ErrBadRequest.WithDebug(err.Error()).WithReasonf("Unable to parse HTTP form request: %s", err.Error())))
 		return
 	}
 
-	var (
-		pid = r.Form.Get("provider") // this can come from both url query and post body
-	)
-
+	var pid = r.Form.Get("provider") // this can come from both url query and post body
 	if pid == "" {
 		s.handleError(w, r, rid, pid, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`The HTTP request did not contain the required "provider" form field`)))
 		return
@@ -157,35 +183,32 @@ func (s *Strategy) handleAuth(w http.ResponseWriter, r *http.Request, ps httprou
 		return
 	}
 
-	ar, err := s.validateRequest(r.Context(), rid)
+	req, err := s.validateRequest(r.Context(), r, rid)
 	if err != nil {
 		s.handleError(w, r, rid, pid, nil, err)
 		return
 	}
 
-	// we assume an error means the user has no session
-	if _, err := s.d.SessionManager().FetchFromRequest(r.Context(), r); err == nil {
-		if !ar.IsForced() {
-			http.Redirect(w, r, s.c.DefaultReturnToURL().String(), http.StatusFound)
-			return
-		}
+	if s.alreadyAuthenticated(w, r, req) {
+		return
 	}
 
 	state := x.NewUUID().String()
-	// Any data that is posted to this endpoint will be used to fill out missing data from the oidc provider.
-	if err := x.SessionPersistValues(w, r, s.d.CookieManager(), sessionName, map[string]interface{}{
-		sessionKeyState:  state,
-		sessionRequestID: rid.String(),
-		sessionFormState: r.PostForm.Encode(),
-	}); err != nil {
+	if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName,
+		continuity.WithPayload(&authCodeContainer{
+			State:     state,
+			RequestID: rid.String(),
+			Form:      r.PostForm,
+		}),
+		continuity.WithLifespan(time.Minute*30)); err != nil {
 		s.handleError(w, r, rid, pid, nil, err)
 		return
 	}
 
-	http.Redirect(w, r, config.AuthCodeURL(state, provider.AuthCodeURLOptions(ar)...), http.StatusFound)
+	http.Redirect(w, r, config.AuthCodeURL(state, provider.AuthCodeURLOptions(req)...), http.StatusFound)
 }
 
-func (s *Strategy) validateRequest(ctx context.Context, rid uuid.UUID) (request, error) {
+func (s *Strategy) validateRequest(ctx context.Context, r *http.Request, rid uuid.UUID) (request, error) {
 	if x.IsZeroUUID(rid) {
 		return nil, errors.WithStack(herodot.ErrBadRequest.WithReason("The session cookie contains invalid values and the request could not be executed. Please try again."))
 	}
@@ -197,42 +220,76 @@ func (s *Strategy) validateRequest(ctx context.Context, rid uuid.UUID) (request,
 		return ar, nil
 	}
 
-	ar, err := s.d.LoginRequestPersister().GetLoginRequest(ctx, rid)
-	if err != nil {
-		return nil, err
+	if ar, err := s.d.LoginRequestPersister().GetLoginRequest(ctx, rid); err == nil {
+		if err := ar.Valid(); err != nil {
+			return ar, err
+		}
+		return ar, nil
 	}
 
-	if err := ar.Valid(); err != nil {
-		return ar, err
+	ar, err := s.d.SettingsRequestPersister().GetSettingsRequest(ctx, rid)
+	if err == nil {
+		sess, err := s.d.SessionManager().FetchFromRequest(ctx, r)
+		if err != nil {
+			return ar, err
+		}
+
+		if err := ar.Valid(sess); err != nil {
+			return ar, err
+		}
+		return ar, nil
 	}
 
-	return ar, nil
+	return ar, err // this must return the error
 }
 
-func (s *Strategy) validateCallback(r *http.Request) (request, error) {
+func (s *Strategy) validateCallback(w http.ResponseWriter, r *http.Request) (request, *authCodeContainer, error) {
 	var (
-		code = r.URL.Query().Get("code")
+		code  = r.URL.Query().Get("code")
+		state = r.URL.Query().Get("state")
 	)
-	if state := r.URL.Query().Get("state"); state == "" {
-		return nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the state query parameter.`))
-	} else if state != x.SessionGetStringOr(r, s.d.CookieManager(), sessionName, sessionKeyState, "") {
-		return nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the session cookie.`))
+
+	if state == "" {
+		return nil, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the state query parameter.`))
+	}
+
+	var container authCodeContainer
+	if _, err := s.d.ContinuityManager().Continue(context.Background(), w, r, sessionName, continuity.WithPayload(&container)); err != nil {
+		return nil, nil, err
 	}
 
-	ar, err := s.validateRequest(r.Context(), x.ParseUUID(x.SessionGetStringOr(r, s.d.CookieManager(), sessionName, sessionRequestID, "")))
+	if state != container.State {
+		return nil, &container, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the session cookie.`))
+	}
+
+	req, err := s.validateRequest(r.Context(), r, x.ParseUUID(container.RequestID))
 	if err != nil {
-		return nil, err
+		return nil, &container, err
 	}
 
 	if r.URL.Query().Get("error") != "" {
-		return ar, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider returned error "%s": %s`, r.URL.Query().Get("error"), r.URL.Query().Get("error_description")))
+		return req, &container, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider returned error "%s": %s`, r.URL.Query().Get("error"), r.URL.Query().Get("error_description")))
 	}
 
 	if code == "" {
-		return ar, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the code query parameter.`))
+		return req, &container, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the code query parameter.`))
+	}
+
+	return req, &container, nil
+}
+
+func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, req interface{}) bool {
+	// we assume an error means the user has no session
+	if _, err := s.d.SessionManager().FetchFromRequest(r.Context(), r); err == nil {
+		if _, ok := req.(*settings.Request); ok {
+			// ignore this if it's a settings request
+		} else if !isForced(req) {
+			http.Redirect(w, r, s.c.DefaultReturnToURL().String(), http.StatusFound)
+			return true
+		}
 	}
 
-	return ar, nil
+	return false
 }
 
 func (s *Strategy) handleCallback(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
@@ -241,57 +298,63 @@ func (s *Strategy) handleCallback(w http.ResponseWriter, r *http.Request, ps htt
 		pid  = ps.ByName("provider")
 	)
 
-	ar, err := s.validateCallback(r)
+	req, container, err := s.validateCallback(w, r)
 	if err != nil {
-		if ar != nil {
-			s.handleError(w, r, ar.GetID(), pid, nil, err)
+		if req != nil {
+			s.handleError(w, r, req.GetID(), pid, nil, err)
 		} else {
 			s.handleError(w, r, x.EmptyUUID, pid, nil, err)
 		}
 		return
 	}
 
-	// we assume an error means the user has no session
-	if _, err := s.d.SessionManager().FetchFromRequest(r.Context(), r); err == nil {
-		if !ar.IsForced() {
-			http.Redirect(w, r, s.c.DefaultReturnToURL().String(), http.StatusFound)
-			return
-		}
+	if s.alreadyAuthenticated(w, r, req) {
+		return
 	}
 
 	provider, err := s.provider(pid)
 	if err != nil {
-		s.handleError(w, r, ar.GetID(), pid, nil, err)
+		s.handleError(w, r, req.GetID(), pid, nil, err)
 		return
 	}
 
 	config, err := provider.OAuth2(context.Background())
 	if err != nil {
-		s.handleError(w, r, ar.GetID(), pid, nil, err)
+		s.handleError(w, r, req.GetID(), pid, nil, err)
 		return
 	}
 
 	token, err := config.Exchange(r.Context(), code)
 	if err != nil {
-		s.handleError(w, r, ar.GetID(), pid, nil, err)
+		s.handleError(w, r, req.GetID(), pid, nil, err)
 		return
 	}
 
 	claims, err := provider.Claims(r.Context(), token)
 	if err != nil {
-		s.handleError(w, r, ar.GetID(), pid, nil, err)
+		s.handleError(w, r, req.GetID(), pid, nil, err)
 		return
 	}
 
-	switch a := ar.(type) {
+	switch a := req.(type) {
 	case *login.Request:
-		s.processLogin(w, r, a, claims, provider)
+		s.processLogin(w, r, a, claims, provider, container)
 		return
 	case *registration.Request:
-		s.processRegistration(w, r, a, claims, provider)
+		s.processRegistration(w, r, a, claims, provider, container)
+		return
+	case *settings.Request:
+		sess, err := s.d.SessionManager().FetchFromRequest(r.Context(), r)
+		if err != nil {
+			s.handleError(w, r, req.GetID(), pid, nil, err)
+			return
+		}
+		s.linkProvider(w, r, &settings.UpdateContext{Session: sess, Request: a}, claims, provider)
 		return
 	default:
-		panic(fmt.Sprintf("unexpected type: %T", a))
+		s.handleError(w, r, req.GetID(), pid, nil, errors.WithStack(x.PseudoPanic.
+			WithDetailf("cause", "Unexpected type in OpenID Connect flow: %T", a)))
+		return
 	}
 }
 
@@ -299,235 +362,34 @@ func uid(provider, subject string) string {
 	return fmt.Sprintf("%s:%s", provider, subject)
 }
 
-func (s *Strategy) authURL(request uuid.UUID, provider string) string {
-	u := urlx.AppendPaths(
+func (s *Strategy) authURL(request uuid.UUID) string {
+	return urlx.AppendPaths(
 		urlx.Copy(s.c.SelfPublicURL()),
 		strings.Replace(
 			AuthPath, ":request", request.String(), 1,
 		),
-	)
-
-	if provider != "" {
-		return urlx.CopyWithQuery(u, url.Values{"provider": {provider}}).String()
-	}
-
-	return u.String()
-}
-
-func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login.Request, claims *Claims, provider Provider) {
-	i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), identity.CredentialsTypeOIDC, uid(provider.Config().ID, claims.Subject))
-	if err != nil {
-		if errors.Is(err, herodot.ErrNotFound) {
-			// If no account was found we're "manually" creating a new registration request and redirecting the browser
-			// to that endpoint.
-
-			// That will execute the "pre registration" hook which allows to e.g. disallow this request. The registration
-			// ui however will NOT be shown, instead the user is directly redirected to the auth path. That should then
-			// do a silent re-request. While this might be a bit excessive from a network perspective it should usually
-			// happen without any downsides to user experience as the request has already been authorized and should
-			// not need additional consent/login.
-
-			// This is kinda hacky but the only way to ensure seamless login/registration flows when using OIDC.
-
-			s.d.Logger().WithField("provider", provider.Config().ID).WithField("subject", claims.Subject).Debug("Received successful OpenID Connect callback but user is not registered. Re-initializing registration flow now.")
-			aa, err := s.d.RegistrationHandler().NewRegistrationRequest(w, r)
-			if err != nil {
-				s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-				return
-			}
-
-			s.processRegistration(w, r, aa, claims, provider)
-			return
-		}
-
-		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-		return
-	}
-
-	var o []CredentialsConfig
-	if err := json.NewDecoder(bytes.NewBuffer(c.Config)).Decode(&o); err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, errors.WithStack(herodot.ErrInternalServerError.WithReason("The password credentials could not be decoded properly").WithDebug(err.Error())))
-		return
-	}
-
-	for _, c := range o {
-		if c.Subject == claims.Subject && c.Provider == provider.Config().ID {
-			if err = s.d.LoginHookExecutor().PostLoginHook(w, r, identity.CredentialsTypeOIDC, a, i); err != nil {
-				s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-				return
-			}
-			return
-		}
-	}
-
-	s.handleError(w, r, a.GetID(), provider.Config().ID, nil, errors.WithStack(herodot.ErrInternalServerError.WithReason("Unable to find matching OpenID Connect Credentials.").WithDebugf(`Unable to find credentials that match the given provider "%s" and subject "%s".`, provider.Config().ID, claims.Subject)))
+	).String()
 }
 
-func (s *Strategy) processRegistration(w http.ResponseWriter, r *http.Request, a *registration.Request, claims *Claims, provider Provider) {
-	if _, _, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), identity.CredentialsTypeOIDC, uid(provider.Config().ID, claims.Subject)); err == nil {
-		// If the identity already exists, we should perform the login flow instead.
-
-		// That will execute the "pre login" hook which allows to e.g. disallow this request. The login
-		// ui however will NOT be shown, instead the user is directly redirected to the auth path. That should then
-		// do a silent re-request. While this might be a bit excessive from a network perspective it should usually
-		// happen without any downsides to user experience as the request has already been authorized and should
-		// not need additional consent/login.
-
-		// This is kinda hacky but the only way to ensure seamless login/registration flows when using OIDC.
-		s.d.Logger().WithField("provider", provider.Config().ID).WithField("subject", claims.Subject).Debug("Received successful OpenID Connect callback but user is already registered. Re-initializing login flow now.")
-		ar, err := s.d.LoginHandler().NewLoginRequest(w, r)
-		if err != nil {
-			s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-			return
-		}
-
-		s.processLogin(w, r, ar, claims, provider)
-		return
-	}
-
-	jn, err := s.f.Fetch(provider.Config().Mapper)
-	if err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-		return
-	}
-
-	var jsonClaims bytes.Buffer
-	if err := json.NewEncoder(&jsonClaims).Encode(claims); err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-		return
-	}
-
-	i := identity.NewIdentity(configuration.DefaultIdentityTraitsSchemaID)
-
-	vm := jsonnet.MakeVM()
-	vm.ExtCode("claims", jsonClaims.String())
-	evaluated, err := vm.EvaluateSnippet(provider.Config().Mapper, jn.String())
-	if err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-		return
-	} else if traits := gjson.Get(evaluated, "identity.traits"); !traits.IsObject() {
-		i.Traits = []byte{'{', '}'}
-		s.d.Logger().
-			WithField("oidc_provider", provider.Config().ID).
-			WithField("oidc_claims", x.RedactInProd(s.c, claims)).
-			WithField("mapper_jsonnet_output", evaluated).
-			WithField("mapper_jsonnet_url", provider.Config().Mapper).
-			Warn("OpenID Connect Jsonnet mapper did not return an object for key identity.traits. Please check your Jsonnet code!")
-	} else {
-		i.Traits = []byte(traits.Raw)
-	}
-	if s.c.IsInsecureDevMode() {
-		s.d.Logger().
-			WithField("oidc_provider", provider.Config().ID).
-			WithField("oidc_claims", x.RedactInProd(s.c, claims)).
-			WithField("mapper_jsonnet_output", evaluated).
-			WithField("mapper_jsonnet_url", provider.Config().Mapper).
-			Debug("OpenID Connect Jsonnet mapper completed.")
-	}
-
-	option, err := decoderRegistration(s.c.DefaultIdentityTraitsSchemaURL().String())
-	if err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-		return
-	}
-
-	i.Traits, err = merge(
-		x.SessionGetStringOr(r, s.d.CookieManager(), sessionName, sessionFormState, ""),
-		json.RawMessage(i.Traits), option,
-	)
-	if err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
-		return
-	}
-
-	// Validate the identity itself
-	if err := s.d.IdentityValidator().Validate(i); err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, i.Traits, err)
-		return
-	}
-
-	var b bytes.Buffer
-	if err := json.NewEncoder(&b).Encode([]CredentialsConfig{
-		{
-			Subject:  claims.Subject,
-			Provider: provider.Config().ID,
-		},
-	}); err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, i.Traits, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to encode password options to JSON: %s", err)))
-		return
-	}
-
-	i.SetCredentials(s.ID(), identity.Credentials{
-		Type:        s.ID(),
-		Identifiers: []string{uid(provider.Config().ID, claims.Subject)},
-		Config:      b.Bytes(),
-	})
-
-	if err := s.d.RegistrationExecutor().PostRegistrationHook(w, r, identity.CredentialsTypeOIDC, a, i); err != nil {
-		s.handleError(w, r, a.GetID(), provider.Config().ID, i.Traits, err)
-		return
-	}
-}
-
-// func (s *Strategy) verifyIdentity(i *identity.Identity, c identity.Credentials, token oidc.IDToken, pid string) error {
-// 	var o CredentialsConfig
-//
-// 	if err := json.NewDecoder(bytes.NewBuffer(c.Config)).Decode(&o); err != nil {
-// 		return errors.WithStack(herodot.ErrInternalServerError.WithReason("The password credentials could not be decoded properly").WithDebug(err.Error()))
-// 	}
-//
-// 	if o.Subject != token.Subject {
-// 		return errors.WithStack(herodot.ErrInternalServerError.WithReason("The subjects do not match").WithDebugf("Expected credential subject to match subject from RequestID Token but values are not equal: %s != %s", o.Subject, token.Subject))
-// 	} else if o.Provider != pid {
-// 		return errors.WithStack(herodot.ErrInternalServerError.WithReason("The providers do not match").WithDebugf("Expected credential provider to match provider from path but values are not equal: %s != %s", o.Subject, pid))
-// 	}
-//
-// 	return nil
-// }
-
 func (s *Strategy) populateMethod(r *http.Request, request uuid.UUID) (*RequestMethod, error) {
 	conf, err := s.Config()
 	if err != nil {
 		return nil, err
 	}
 
-	f := form.NewHTMLForm(s.authURL(request, ""))
+	f := form.NewHTMLForm(s.authURL(request))
 	f.SetCSRF(s.d.GenerateCSRFToken(r))
 	// does not need sorting because there is only one field
 
 	return NewRequestMethodConfig(f).AddProviders(conf.Providers), nil
 }
 
-func (s *Strategy) PopulateLoginMethod(r *http.Request, sr *login.Request) error {
-	config, err := s.populateMethod(r, sr.ID)
-	if err != nil {
-		return err
-	}
-	sr.Methods[identity.CredentialsTypeOIDC] = &login.RequestMethod{
-		Method: identity.CredentialsTypeOIDC,
-		Config: &login.RequestMethodConfig{RequestMethodConfigurator: config},
-	}
-	return nil
-}
-
-func (s *Strategy) PopulateRegistrationMethod(r *http.Request, sr *registration.Request) error {
-	config, err := s.populateMethod(r, sr.ID)
-	if err != nil {
-		return err
-	}
-	sr.Methods[identity.CredentialsTypeOIDC] = &registration.RequestMethod{
-		Method: identity.CredentialsTypeOIDC,
-		Config: &registration.RequestMethodConfig{RequestMethodConfigurator: config},
-	}
-	return nil
-}
-
 func (s *Strategy) Config() (*ConfigurationCollection, error) {
 	var c ConfigurationCollection
 
 	if err := jsonx.
 		NewStrictDecoder(
-			bytes.NewBuffer(s.c.SelfServiceStrategy(string(identity.CredentialsTypeOIDC)).Config),
+			bytes.NewBuffer(s.c.SelfServiceStrategy(string(s.ID())).Config),
 		).
 		Decode(&c); err != nil {
 		return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to decode OpenID Connect Provider configuration: %s", err))
@@ -553,7 +415,10 @@ func (s *Strategy) handleError(w http.ResponseWriter, r *http.Request, rid uuid.
 	}
 
 	if lr, rerr := s.d.LoginRequestPersister().GetLoginRequest(r.Context(), rid); rerr == nil {
-		s.d.LoginRequestErrorHandler().HandleLoginError(w, r, identity.CredentialsTypeOIDC, lr, err)
+		s.d.LoginRequestErrorHandler().HandleLoginError(w, r, s.ID(), lr, err)
+		return
+	} else if sr, rerr := s.d.SettingsRequestPersister().GetSettingsRequest(r.Context(), rid); rerr == nil {
+		s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, sr, err, s.SettingsStrategyID())
 		return
 	} else if rr, rerr := s.d.RegistrationRequestPersister().GetRegistrationRequest(r.Context(), rid); rerr == nil {
 		if method, ok := rr.Methods[s.ID()]; ok {
@@ -567,22 +432,23 @@ func (s *Strategy) handleError(w http.ResponseWriter, r *http.Request, rid uuid.
 			}
 
 			if errSec := method.Config.ParseError(err); errSec != nil {
-				s.d.RegistrationRequestErrorHandler().HandleRegistrationError(w, r, identity.CredentialsTypeOIDC, rr, errors.Wrap(err, errSec.Error()))
+				s.d.RegistrationRequestErrorHandler().HandleRegistrationError(w, r, s.ID(), rr, errors.Wrap(err, errSec.Error()))
 				return
 			}
 			method.Config.ResetErrors()
 
 			method.Config.SetCSRF(s.d.GenerateCSRFToken(r))
 			if errSec := method.Config.SortFields(s.c.DefaultIdentityTraitsSchemaURL().String(), "traits"); errSec != nil {
-				s.d.RegistrationRequestErrorHandler().HandleRegistrationError(w, r, identity.CredentialsTypeOIDC, rr, errors.Wrap(err, errSec.Error()))
+				s.d.RegistrationRequestErrorHandler().HandleRegistrationError(w, r, s.ID(), rr, errors.Wrap(err, errSec.Error()))
 				return
 			}
 
+			method.Config.UnsetField("provider")
 			method.Config.SetField(form.Field{Name: "provider", Value: provider, Type: "submit"})
 			rr.Methods[s.ID()] = method
 		}
 
-		s.d.RegistrationRequestErrorHandler().HandleRegistrationError(w, r, identity.CredentialsTypeOIDC, rr, err)
+		s.d.RegistrationRequestErrorHandler().HandleRegistrationError(w, r, s.ID(), rr, err)
 		return
 	}
 
diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go
new file mode 100644
index 000000000..3c38a7873
--- /dev/null
+++ b/selfservice/strategy/oidc/strategy_login.go
@@ -0,0 +1,80 @@
+package oidc
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+
+	"github.com/pkg/errors"
+
+	"github.com/ory/herodot"
+
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/selfservice/flow/login"
+	"github.com/ory/kratos/x"
+)
+
+var _ login.Strategy = new(Strategy)
+
+func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) {
+	s.setRoutes(r)
+}
+
+func (s *Strategy) PopulateLoginMethod(r *http.Request, sr *login.Request) error {
+	config, err := s.populateMethod(r, sr.ID)
+	if err != nil {
+		return err
+	}
+	sr.Methods[s.ID()] = &login.RequestMethod{Method: s.ID(),
+		Config: &login.RequestMethodConfig{RequestMethodConfigurator: config}}
+	return nil
+}
+
+func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login.Request, claims *Claims, provider Provider, container *authCodeContainer) {
+	i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), identity.CredentialsTypeOIDC, uid(provider.Config().ID, claims.Subject))
+	if err != nil {
+		if errors.Is(err, herodot.ErrNotFound) {
+			// If no account was found we're "manually" creating a new registration request and redirecting the browser
+			// to that endpoint.
+
+			// That will execute the "pre registration" hook which allows to e.g. disallow this request. The registration
+			// ui however will NOT be shown, instead the user is directly redirected to the auth path. That should then
+			// do a silent re-request. While this might be a bit excessive from a network perspective it should usually
+			// happen without any downsides to user experience as the request has already been authorized and should
+			// not need additional consent/login.
+
+			// This is kinda hacky but the only way to ensure seamless login/registration flows when using OIDC.
+
+			s.d.Logger().WithField("provider", provider.Config().ID).WithField("subject", claims.Subject).Debug("Received successful OpenID Connect callback but user is not registered. Re-initializing registration flow now.")
+			aa, err := s.d.RegistrationHandler().NewRegistrationRequest(w, r)
+			if err != nil {
+				s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+				return
+			}
+
+			s.processRegistration(w, r, aa, claims, provider, container)
+			return
+		}
+
+		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+		return
+	}
+
+	var o CredentialsConfig
+	if err := json.NewDecoder(bytes.NewBuffer(c.Config)).Decode(&o); err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, errors.WithStack(herodot.ErrInternalServerError.WithReason("The password credentials could not be decoded properly").WithDebug(err.Error())))
+		return
+	}
+
+	for _, c := range o.Providers {
+		if c.Subject == claims.Subject && c.Provider == provider.Config().ID {
+			if err = s.d.LoginHookExecutor().PostLoginHook(w, r, identity.CredentialsTypeOIDC, a, i); err != nil {
+				s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+				return
+			}
+			return
+		}
+	}
+
+	s.handleError(w, r, a.GetID(), provider.Config().ID, nil, errors.WithStack(herodot.ErrInternalServerError.WithReason("Unable to find matching OpenID Connect Credentials.").WithDebugf(`Unable to find credentials that match the given provider "%s" and subject "%s".`, provider.Config().ID, claims.Subject)))
+}
diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go
new file mode 100644
index 000000000..749d4fe2c
--- /dev/null
+++ b/selfservice/strategy/oidc/strategy_registration.go
@@ -0,0 +1,137 @@
+package oidc
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+
+	"github.com/google/go-jsonnet"
+	"github.com/tidwall/gjson"
+
+	"github.com/ory/kratos/driver/configuration"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/selfservice/flow/registration"
+	"github.com/ory/kratos/x"
+)
+
+const (
+	registrationFormPayloadSchema = `{
+  "$id": "https://schemas.ory.sh/kratos/selfservice/oidc/registration/config.schema.json",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "type": "object",
+  "properties": {
+    "traits": {}
+  }
+}`
+)
+
+var _ registration.Strategy = new(Strategy)
+
+func (s *Strategy) RegisterRegistrationRoutes(r *x.RouterPublic) {
+	s.setRoutes(r)
+}
+
+func (s *Strategy) PopulateRegistrationMethod(r *http.Request, sr *registration.Request) error {
+	config, err := s.populateMethod(r, sr.ID)
+	if err != nil {
+		return err
+	}
+	sr.Methods[s.ID()] = &registration.RequestMethod{
+		Method: s.ID(),
+		Config: &registration.RequestMethodConfig{RequestMethodConfigurator: config},
+	}
+	return nil
+}
+
+func (s *Strategy) processRegistration(w http.ResponseWriter, r *http.Request, a *registration.Request, claims *Claims, provider Provider, container *authCodeContainer) {
+	if _, _, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), identity.CredentialsTypeOIDC, uid(provider.Config().ID, claims.Subject)); err == nil {
+		// If the identity already exists, we should perform the login flow instead.
+
+		// That will execute the "pre login" hook which allows to e.g. disallow this request. The login
+		// ui however will NOT be shown, instead the user is directly redirected to the auth path. That should then
+		// do a silent re-request. While this might be a bit excessive from a network perspective it should usually
+		// happen without any downsides to user experience as the request has already been authorized and should
+		// not need additional consent/login.
+
+		// This is kinda hacky but the only way to ensure seamless login/registration flows when using OIDC.
+		s.d.Logger().WithField("provider", provider.Config().ID).WithField("subject", claims.Subject).Debug("Received successful OpenID Connect callback but user is already registered. Re-initializing login flow now.")
+		ar, err := s.d.LoginHandler().NewLoginRequest(w, r)
+		if err != nil {
+			s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+			return
+		}
+
+		s.processLogin(w, r, ar, claims, provider, container)
+		return
+	}
+
+	jn, err := s.f.Fetch(provider.Config().Mapper)
+	if err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+		return
+	}
+
+	var jsonClaims bytes.Buffer
+	if err := json.NewEncoder(&jsonClaims).Encode(claims); err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+		return
+	}
+
+	i := identity.NewIdentity(configuration.DefaultIdentityTraitsSchemaID)
+
+	vm := jsonnet.MakeVM()
+	vm.ExtCode("claims", jsonClaims.String())
+	evaluated, err := vm.EvaluateSnippet(provider.Config().Mapper, jn.String())
+	if err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+		return
+	} else if traits := gjson.Get(evaluated, "identity.traits"); !traits.IsObject() {
+		i.Traits = []byte{'{', '}'}
+		s.d.Logger().
+			WithField("oidc_provider", provider.Config().ID).
+			WithField("oidc_claims", x.RedactInProd(s.c, claims)).
+			WithField("mapper_jsonnet_output", evaluated).
+			WithField("mapper_jsonnet_url", provider.Config().Mapper).
+			Warn("OpenID Connect Jsonnet mapper did not return an object for key identity.traits. Please check your Jsonnet code!")
+	} else {
+		i.Traits = []byte(traits.Raw)
+	}
+	if s.c.IsInsecureDevMode() {
+		s.d.Logger().
+			WithField("oidc_provider", provider.Config().ID).
+			WithField("oidc_claims", x.RedactInProd(s.c, claims)).
+			WithField("mapper_jsonnet_output", evaluated).
+			WithField("mapper_jsonnet_url", provider.Config().Mapper).
+			Debug("OpenID Connect Jsonnet mapper completed.")
+	}
+
+	option, err := decoderRegistration(s.c.DefaultIdentityTraitsSchemaURL().String())
+	if err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+		return
+	}
+
+	i.Traits, err = merge(container.Form.Encode(), json.RawMessage(i.Traits), option)
+	if err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, nil, err)
+		return
+	}
+
+	// Validate the identity itself
+	if err := s.d.IdentityValidator().Validate(i); err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, i.Traits, err)
+		return
+	}
+
+	creds, err := NewCredentials(provider.Config().ID, claims.Subject)
+	if err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, i.Traits, err)
+		return
+	}
+
+	i.SetCredentials(s.ID(), *creds)
+	if err := s.d.RegistrationExecutor().PostRegistrationHook(w, r, identity.CredentialsTypeOIDC, a, i); err != nil {
+		s.handleError(w, r, a.GetID(), provider.Config().ID, i.Traits, err)
+		return
+	}
+}
diff --git a/selfservice/strategy/oidc/strategy_settings.go b/selfservice/strategy/oidc/strategy_settings.go
new file mode 100644
index 000000000..1d28adbfb
--- /dev/null
+++ b/selfservice/strategy/oidc/strategy_settings.go
@@ -0,0 +1,436 @@
+package oidc
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/gobuffalo/uuid"
+	"github.com/julienschmidt/httprouter"
+	"github.com/pkg/errors"
+
+	"github.com/ory/herodot"
+	"github.com/ory/jsonschema/v3"
+	"github.com/ory/x/urlx"
+
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/selfservice/flow/settings"
+	"github.com/ory/kratos/selfservice/form"
+	"github.com/ory/kratos/session"
+	"github.com/ory/kratos/x"
+)
+
+const (
+	SettingsPath = BasePath + "/settings/connections"
+)
+
+var _ settings.Strategy = new(Strategy)
+var UnknownConnectionValidationError = &jsonschema.ValidationError{
+	Message: "can not unlink non-existing OpenID Connect connection", InstancePtr: "#/"}
+var ConnectionExistValidationError = &jsonschema.ValidationError{
+	Message: "can not link unknown or already existing OpenID Connect connection", InstancePtr: "#/"}
+
+func (s *Strategy) RegisterSettingsRoutes(router *x.RouterPublic) {
+	router.POST(SettingsPath, s.completeSettingsFlow)
+	router.GET(SettingsPath, s.completeSettingsFlow)
+}
+
+func (s *Strategy) SettingsStrategyID() string {
+	return s.ID().String()
+}
+
+func (s *Strategy) linkedProviders(conf *ConfigurationCollection, confidential *identity.Identity) ([]Provider, error) {
+	creds, ok := confidential.GetCredentials(s.ID())
+	if !ok {
+		return nil, nil
+	}
+
+	var available CredentialsConfig
+	if err := json.Unmarshal(creds.Config, &available); err != nil {
+		return nil, errors.WithStack(err)
+	}
+
+	var count int
+	for _, strategy := range s.d.ActiveCredentialsCounterStrategies() {
+		current, err := strategy.CountActiveCredentials(confidential.Credentials)
+		if err != nil {
+			return nil, err
+		}
+
+		count += current
+		if count > 1 {
+			break
+		}
+	}
+
+	if count < 2 {
+		// This means that we're able to remove a connection because it is the last configured credential. If it is
+		// removed, the identity is no longer able to sign in.
+		return nil, nil
+	}
+
+	var result []Provider
+	for _, p := range available.Providers {
+		prov, err := conf.Provider(p.Provider, s.c.SelfPublicURL())
+		if err != nil {
+			return nil, err
+		}
+		result = append(result, prov)
+	}
+
+	return result, nil
+}
+
+func (s *Strategy) linkableProviders(conf *ConfigurationCollection, confidential *identity.Identity) ([]Provider, error) {
+	var available CredentialsConfig
+	creds, ok := confidential.GetCredentials(s.ID())
+	if ok {
+		if err := json.Unmarshal(creds.Config, &available); err != nil {
+			return nil, errors.WithStack(err)
+		}
+	}
+
+	var result []Provider
+	for _, p := range conf.Providers {
+		var found bool
+		for _, pp := range available.Providers {
+			if pp.Provider == p.ID {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			prov, err := conf.Provider(p.ID, s.c.SelfPublicURL())
+			if err != nil {
+				return nil, err
+			}
+			result = append(result, prov)
+		}
+	}
+
+	return result, nil
+}
+
+func (s *Strategy) PopulateSettingsMethod(r *http.Request, ss *session.Session, sr *settings.Request) error {
+	conf, err := s.Config()
+	if err != nil {
+		return err
+	}
+
+	confidential, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), ss.IdentityID)
+	if err != nil {
+		return err
+	}
+
+	linkable, err := s.linkableProviders(conf, confidential)
+	if err != nil {
+		return err
+	}
+
+	linked, err := s.linkedProviders(conf, confidential)
+	if err != nil {
+		return err
+	}
+
+	f := form.NewHTMLForm(urlx.CopyWithQuery(urlx.AppendPaths(
+		s.c.SelfPublicURL(), SettingsPath), url.Values{"request": {sr.ID.String()}}).String())
+	f.SetCSRF(s.d.GenerateCSRFToken(r))
+
+	for _, l := range linkable {
+		f.Fields = append(f.Fields, form.Field{
+			Name:  "link",
+			Type:  "submit",
+			Value: l.Config().ID,
+		})
+	}
+
+	for _, l := range linked {
+		f.Fields = append(f.Fields, form.Field{
+			Name:  "unlink",
+			Type:  "submit",
+			Value: l.Config().ID,
+		})
+	}
+
+	sr.Methods[s.SettingsStrategyID()] = &settings.RequestMethod{
+		Method: s.SettingsStrategyID(),
+		Config: &settings.RequestMethodConfig{RequestMethodConfigurator: NewRequestMethodConfig(f)},
+	}
+
+	return nil
+}
+
+// swagger:model completeSelfServiceBrowserSettingsOIDCFlowPayload
+type completeSelfServiceBrowserSettingsOIDCFlowPayload struct {
+	// Link this provider
+	//
+	// Either this or `unlink` must be set.
+	//
+	// type: string
+	// in: body
+	Link string `json:"link"`
+
+	// Unlink this provider
+	//
+	// Either this or `link` must be set.
+	//
+	// type: string
+	// in: body
+	Unlink string `json:"unlink"`
+
+	// RequestID is request ID.
+	//
+	// in: query
+	RequestID string `json:"request_id"`
+}
+
+func (p *completeSelfServiceBrowserSettingsOIDCFlowPayload) GetRequestID() uuid.UUID {
+	return x.ParseUUID(p.RequestID)
+}
+
+func (p *completeSelfServiceBrowserSettingsOIDCFlowPayload) SetRequestID(rid uuid.UUID) {
+	p.RequestID = rid.String()
+}
+
+// swagger:route POST /self-service/browser/flows/registration/strategies/oidc/settings/connections public completeSelfServiceBrowserSettingsOIDCSettingsFlow
+//
+// Complete the browser-based settings flow for the OpenID Connect strategy
+//
+// This endpoint completes a browser-based settings flow. This is usually achieved by POSTing data to this
+// endpoint.
+//
+// > This endpoint is NOT INTENDED for API clients and only works with browsers (Chrome, Firefox, ...) and HTML Forms.
+//
+// More information can be found at [ORY Kratos User Settings & Profile Management Documentation](https://www.ory.sh/docs/next/kratos/self-service/flows/user-settings-profile-management).
+//
+//     Consumes:
+//     - application/json
+//     - application/x-www-form-urlencoded
+//
+//     Schemes: http, https
+//
+//     Responses:
+//       302: emptyResponse
+//       500: genericError
+func (s *Strategy) completeSettingsFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
+	var p completeSelfServiceBrowserSettingsOIDCFlowPayload
+	ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, settings.ContinuityKey(s.SettingsStrategyID()), &p)
+	if errors.Is(err, settings.ErrContinuePreviousAction) {
+		if l := len(p.Link); l > 0 {
+			s.initLinkProvider(w, r, ctxUpdate, &p)
+			return
+		} else if u := len(p.Unlink); u > 0 {
+			s.unlinkProvider(w, r, ctxUpdate, &p)
+			return
+		}
+
+		s.handleSettingsError(w, r, ctxUpdate, &p, errors.WithStack(herodot.ErrInternalServerError.WithReason("Expected either link or unlink to be set when continuing flow but both are unset.")))
+		return
+	} else if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, &p, err)
+		return
+	}
+
+	p.Link = r.Form.Get("link")
+	p.Unlink = r.Form.Get("unlink")
+	if l, u := len(p.Link), len(p.Unlink); l > 0 && u > 0 {
+		s.handleSettingsError(w, r, ctxUpdate, &p, errors.WithStack(&jsonschema.ValidationError{
+			Message:     "it is not possible to link and unlink providers in the same request",
+			InstancePtr: "#/",
+		}))
+		return
+	} else if l > 0 {
+		s.initLinkProvider(w, r, ctxUpdate, &p)
+		return
+	} else if u > 0 {
+		s.unlinkProvider(w, r, ctxUpdate, &p)
+		return
+	}
+
+	s.handleSettingsError(w, r, ctxUpdate, &p, errors.WithStack(errors.WithStack(&jsonschema.ValidationError{
+		Message: "missing properties: link, unlink", InstancePtr: "#/",
+		Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"link", "unlink"}}})))
+}
+
+func (s *Strategy) isLinkable(r *http.Request, ctxUpdate *settings.UpdateContext, toLink string) (*identity.Identity, error) {
+	providers, err := s.Config()
+	if err != nil {
+		return nil, err
+	}
+
+	i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), ctxUpdate.Session.Identity.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	linkable, err := s.linkableProviders(providers, i)
+	if err != nil {
+		return nil, err
+	}
+
+	var found bool
+	for _, available := range linkable {
+		if toLink == available.Config().ID {
+			found = true
+		}
+	}
+
+	if !found {
+		return nil, errors.WithStack(ConnectionExistValidationError)
+	}
+
+	return i, nil
+}
+
+func (s *Strategy) initLinkProvider(w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext,
+	p *completeSelfServiceBrowserSettingsOIDCFlowPayload) {
+	if _, err := s.isLinkable(r, ctxUpdate, p.Link); err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+
+	if ctxUpdate.Session.AuthenticatedAt.Add(s.c.SelfServicePrivilegedSessionMaxAge()).Before(time.Now()) {
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(settings.ErrRequestNeedsReAuthentication))
+		return
+	}
+
+	http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.c.SelfPublicURL(),
+		strings.Replace(AuthPath, ":request", p.RequestID, 1)),
+		url.Values{"provider": {p.Link}}).String(), http.StatusFound)
+}
+
+func (s *Strategy) linkProvider(w http.ResponseWriter, r *http.Request,
+	ctxUpdate *settings.UpdateContext, claims *Claims, provider Provider) {
+	p := &completeSelfServiceBrowserSettingsOIDCFlowPayload{
+		Link: provider.Config().ID, RequestID: ctxUpdate.Request.ID.String()}
+	if ctxUpdate.Session.AuthenticatedAt.Add(s.c.SelfServicePrivilegedSessionMaxAge()).Before(time.Now()) {
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(settings.ErrRequestNeedsReAuthentication))
+		return
+	}
+
+	i, err := s.isLinkable(r, ctxUpdate, p.Link)
+	if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+
+	var conf CredentialsConfig
+	creds, err := i.ParseCredentials(s.ID(), &conf)
+	if errors.Is(err, herodot.ErrNotFound) {
+		var err error
+		if creds, err = NewCredentials(provider.Config().ID, claims.Subject); err != nil {
+			s.handleSettingsError(w, r, ctxUpdate, p, err)
+			return
+		}
+	} else if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+
+	creds.Identifiers = append(creds.Identifiers, uid(provider.Config().ID, claims.Subject))
+	conf.Providers = append(conf.Providers, ProviderCredentialsConfig{
+		Subject: claims.Subject, Provider: provider.Config().ID})
+
+	creds.Config, err = json.Marshal(conf)
+	if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+
+	i.Credentials[s.ID()] = *creds
+	if err := s.d.SettingsHookExecutor().PostSettingsHook(w, r, s.SettingsStrategyID(), ctxUpdate, i, settings.WithCallback(func(ctxUpdate *settings.UpdateContext) error {
+		return s.PopulateSettingsMethod(r, ctxUpdate.Session, ctxUpdate.Request)
+	})); err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+}
+
+func (s *Strategy) unlinkProvider(w http.ResponseWriter, r *http.Request,
+	ctxUpdate *settings.UpdateContext, p *completeSelfServiceBrowserSettingsOIDCFlowPayload) {
+	if ctxUpdate.Session.AuthenticatedAt.Add(s.c.SelfServicePrivilegedSessionMaxAge()).Before(time.Now()) {
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(settings.ErrRequestNeedsReAuthentication))
+		return
+	}
+
+	providers, err := s.Config()
+	if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+
+	i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), ctxUpdate.Session.Identity.ID)
+	if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+
+	availableProviders, err := s.linkedProviders(providers, i)
+	if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+
+	var cc CredentialsConfig
+	creds, err := i.ParseCredentials(s.ID(), &cc)
+	if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(UnknownConnectionValidationError))
+		return
+	}
+
+	var found bool
+	var updatedProviders []ProviderCredentialsConfig
+	var updatedIdentifiers []string
+	for _, available := range availableProviders {
+		if p.Unlink == available.Config().ID {
+			for _, link := range cc.Providers {
+				if link.Provider != p.Unlink {
+					updatedIdentifiers = append(updatedIdentifiers, uid(link.Provider, link.Subject))
+					updatedProviders = append(updatedProviders, link)
+				} else {
+					found = true
+				}
+			}
+		}
+	}
+
+	if !found {
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(UnknownConnectionValidationError))
+		return
+	}
+
+	creds.Identifiers = updatedIdentifiers
+	creds.Config, err = json.Marshal(&CredentialsConfig{updatedProviders})
+	if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(err))
+		return
+	}
+
+	i.Credentials[s.ID()] = *creds
+	if err := s.d.SettingsHookExecutor().PostSettingsHook(w, r, s.SettingsStrategyID(), ctxUpdate, i, settings.WithCallback(func(ctxUpdate *settings.UpdateContext) error {
+		return s.PopulateSettingsMethod(r, ctxUpdate.Session, ctxUpdate.Request)
+	})); err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
+		return
+	}
+}
+
+func (s *Strategy) handleSettingsError(w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *completeSelfServiceBrowserSettingsOIDCFlowPayload, err error) {
+	if errors.Is(err, settings.ErrRequestNeedsReAuthentication) {
+		if err := s.d.ContinuityManager().Pause(r.Context(), w, r,
+			settings.ContinuityKey(s.SettingsStrategyID()), settings.ContinuityOptions(p, ctxUpdate.Session.Identity)...); err != nil {
+			s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ctxUpdate.Request, err, s.SettingsStrategyID())
+			return
+		}
+	}
+
+	if ctxUpdate.Request != nil {
+		ctxUpdate.Request.Methods[s.SettingsStrategyID()].Config.ResetErrors()
+		ctxUpdate.Request.Methods[s.SettingsStrategyID()].Config.SetCSRF(s.d.GenerateCSRFToken(r))
+	}
+
+	s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ctxUpdate.Request, err, s.SettingsStrategyID())
+}
diff --git a/selfservice/strategy/oidc/stub/settings.schema.json b/selfservice/strategy/oidc/stub/settings.schema.json
new file mode 100644
index 000000000..e1883cc9e
--- /dev/null
+++ b/selfservice/strategy/oidc/stub/settings.schema.json
@@ -0,0 +1,27 @@
+{
+  "$id": "https://example.com/person.schema.json",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "Person",
+  "type": "object",
+  "properties": {
+    "email": {
+      "format": "email",
+      "type": "string",
+      "ory.sh/kratos": {
+        "credentials": {
+          "password": {
+            "identifier": true
+          }
+        }
+      }
+    },
+    "name": {
+      "type": "string",
+      "minLength": 2
+    }
+  },
+  "required": [
+    "email"
+  ],
+  "additionalProperties": false
+}
diff --git a/selfservice/strategy/oidc/types.go b/selfservice/strategy/oidc/types.go
index 38f64916a..3dda10531 100644
--- a/selfservice/strategy/oidc/types.go
+++ b/selfservice/strategy/oidc/types.go
@@ -1,13 +1,39 @@
 package oidc
 
 import (
+	"bytes"
+	"encoding/json"
+
 	"github.com/gofrs/uuid"
+	"github.com/pkg/errors"
 
+	"github.com/ory/kratos/identity"
 	"github.com/ory/kratos/selfservice/form"
+	"github.com/ory/kratos/x"
 )
 
 // swagger:model oidcStrategyCredentialsConfig
 type CredentialsConfig struct {
+	Providers []ProviderCredentialsConfig `json:"providers"`
+}
+
+func NewCredentials(provider, subject string) (*identity.Credentials, error) {
+	var b bytes.Buffer
+	if err := json.NewEncoder(&b).Encode(CredentialsConfig{
+		Providers: []ProviderCredentialsConfig{{Subject: subject, Provider: provider}},
+	}); err != nil {
+		return nil, errors.WithStack(x.PseudoPanic.
+			WithDebugf("Unable to encode password options to JSON: %s", err))
+	}
+
+	return &identity.Credentials{
+		Type:        identity.CredentialsTypeOIDC,
+		Identifiers: []string{uid(provider, subject)},
+		Config:      b.Bytes(),
+	}, nil
+}
+
+type ProviderCredentialsConfig struct {
 	Subject  string `json:"subject"`
 	Provider string `json:"provider"`
 }
@@ -30,5 +56,4 @@ func NewRequestMethodConfig(f *form.HTMLForm) *RequestMethod {
 
 type request interface {
 	GetID() uuid.UUID
-	IsForced() bool
 }
diff --git a/selfservice/strategy/password/settings.go b/selfservice/strategy/password/settings.go
index 3b7a9de23..62c099d9d 100644
--- a/selfservice/strategy/password/settings.go
+++ b/selfservice/strategy/password/settings.go
@@ -6,15 +6,13 @@ import (
 	"net/url"
 	"time"
 
+	"github.com/gofrs/uuid"
 	"github.com/julienschmidt/httprouter"
 	"github.com/pkg/errors"
 
-	"github.com/ory/x/sqlxx"
-
 	"github.com/ory/herodot"
 	"github.com/ory/x/urlx"
 
-	"github.com/ory/kratos/continuity"
 	"github.com/ory/kratos/identity"
 	"github.com/ory/kratos/schema"
 	"github.com/ory/kratos/selfservice/flow/settings"
@@ -27,11 +25,6 @@ const (
 	SettingsPath = "/self-service/browser/flows/settings/strategies/password"
 )
 
-func continuityKeySettings(rid string) string {
-	// Use one individual container per request ID to prevent resuming other request IDs.
-	return "settings_password." + rid
-}
-
 func (s *Strategy) RegisterSettingsRoutes(router *x.RouterPublic) {
 	router.POST(SettingsPath, s.submitSettingsFlow)
 	router.GET(SettingsPath, s.submitSettingsFlow)
@@ -56,6 +49,14 @@ type completeSelfServiceBrowserSettingsPasswordFlowPayload struct {
 	RequestID string `json:"request_id"`
 }
 
+func (p *completeSelfServiceBrowserSettingsPasswordFlowPayload) GetRequestID() uuid.UUID {
+	return x.ParseUUID(p.RequestID)
+}
+
+func (p *completeSelfServiceBrowserSettingsPasswordFlowPayload) SetRequestID(rid uuid.UUID) {
+	p.RequestID = rid.String()
+}
+
 // swagger:route POST /self-service/browser/flows/settings/strategies/password public completeSelfServiceBrowserSettingsPasswordStrategyFlow
 //
 // Complete the browser-based settings flow for the password strategy
@@ -77,96 +78,50 @@ type completeSelfServiceBrowserSettingsPasswordFlowPayload struct {
 //       302: emptyResponse
 //       500: genericError
 func (s *Strategy) submitSettingsFlow(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
-	ss, err := s.d.SessionManager().FetchFromRequest(r.Context(), r)
-	if err != nil {
-		s.handleSettingsError(w, r, nil, ss, nil, err)
-		return
-	}
-
-	rid := r.URL.Query().Get("request")
-	if len(rid) == 0 {
-		s.handleSettingsError(w, r, nil, ss, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf("The request query parameter is missing.")))
-		return
-	}
-
 	var p completeSelfServiceBrowserSettingsPasswordFlowPayload
-	if _, err := s.d.ContinuityManager().Continue(r.Context(), r,
-		continuityKeySettings(r.URL.Query().Get("request")),
-		continuity.WithIdentity(ss.Identity),
-		continuity.WithPayload(&p)); err == nil {
-		if p.RequestID == r.URL.Query().Get("request") {
-			s.completeSettingsFlow(w, r, ss, &p)
-			return
-		}
+	ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, settings.ContinuityKey(s.SettingsStrategyID()), &p)
+	if errors.Is(err, settings.ErrContinuePreviousAction) {
+		s.continueSettingsFlow(w, r, ctxUpdate, &p)
 		return
-	} else if !errors.Is(err, &continuity.ErrNotResumable) {
-		s.handleSettingsError(w, r, nil, ss, &p, err)
+	} else if err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, &p, err)
 		return
 	}
 
-	if err := r.ParseForm(); err != nil {
-		s.handleSettingsError(w, r, nil, ss, &p, errors.WithStack(err))
-		return
-	}
-
-	p = completeSelfServiceBrowserSettingsPasswordFlowPayload{
-		RequestID: rid,
-		Password:  r.PostFormValue("password"),
-	}
-	if err := s.d.ContinuityManager().Pause(
-		r.Context(), w, r,
-		continuityKeySettings(r.URL.Query().Get("request")),
-		continuity.WithPayload(&p),
-		continuity.WithIdentity(ss.Identity),
-		continuity.WithLifespan(time.Minute*15),
-	); err != nil {
-		s.handleSettingsError(w, r, nil, ss, &p, errors.WithStack(err))
-		return
-	}
-
-	s.completeSettingsFlow(w, r, ss, &p)
+	p.RequestID = ctxUpdate.Request.ID.String()
+	p.Password = r.PostFormValue("password")
+	s.continueSettingsFlow(w, r, ctxUpdate, &p)
 }
 
-func (s *Strategy) completeSettingsFlow(
+func (s *Strategy) continueSettingsFlow(
 	w http.ResponseWriter, r *http.Request,
-	ss *session.Session, p *completeSelfServiceBrowserSettingsPasswordFlowPayload,
+	ctxUpdate *settings.UpdateContext, p *completeSelfServiceBrowserSettingsPasswordFlowPayload,
 ) {
-	ar, err := s.d.SettingsRequestPersister().GetSettingsRequest(r.Context(), x.ParseUUID(p.RequestID))
-	if err != nil {
-		s.handleSettingsError(w, r, nil, ss, p, err)
-		return
-	}
-
-	if err := ar.Valid(ss); err != nil {
-		s.handleSettingsError(w, r, ar, ss, p, err)
-		return
-	}
-
-	if ss.AuthenticatedAt.Add(s.c.SelfServicePrivilegedSessionMaxAge()).Before(time.Now()) {
-		s.handleSettingsError(w, r, ar, ss, p, errors.WithStack(settings.ErrRequestNeedsReAuthentication))
+	if ctxUpdate.Session.AuthenticatedAt.Add(s.c.SelfServicePrivilegedSessionMaxAge()).Before(time.Now()) {
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(settings.ErrRequestNeedsReAuthentication))
 		return
 	}
 
 	if len(p.Password) == 0 {
-		s.handleSettingsError(w, r, ar, ss, p, schema.NewRequiredError("#/", "password"))
+		s.handleSettingsError(w, r, ctxUpdate, p, schema.NewRequiredError("#/", "password"))
 		return
 	}
 
 	hpw, err := s.d.PasswordHasher().Generate([]byte(p.Password))
 	if err != nil {
-		s.handleSettingsError(w, r, ar, ss, p, err)
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
 		return
 	}
 
 	co, err := json.Marshal(&CredentialsConfig{HashedPassword: string(hpw)})
 	if err != nil {
-		s.handleSettingsError(w, r, ar, ss, p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to encode password options to JSON: %s", err)))
+		s.handleSettingsError(w, r, ctxUpdate, p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to encode password options to JSON: %s", err)))
 		return
 	}
 
-	i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), ss.Identity.ID)
+	i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), ctxUpdate.Session.Identity.ID)
 	if err != nil {
-		s.handleSettingsError(w, r, ar, ss, p, err)
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
 		return
 	}
 
@@ -180,24 +135,15 @@ func (s *Strategy) completeSettingsFlow(
 	c.Config = co
 	i.SetCredentials(s.ID(), *c)
 	if err := s.validateCredentials(i, p.Password); err != nil {
-		s.handleSettingsError(w, r, ar, ss, p, err)
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
 		return
 	}
 
 	if err := s.d.SettingsHookExecutor().PostSettingsHook(w, r,
-		s.SettingsStrategyID(),
-		ar, ss, i,
-	); err != nil {
-		s.handleSettingsError(w, r, ar, ss, p, err)
+		s.SettingsStrategyID(), ctxUpdate, i); err != nil {
+		s.handleSettingsError(w, r, ctxUpdate, p, err)
 		return
 	}
-
-	if len(w.Header().Get("Location")) == 0 {
-		http.Redirect(w, r,
-			urlx.CopyWithQuery(s.c.SettingsURL(), url.Values{"request": {ar.ID.String()}}).String(),
-			http.StatusFound,
-		)
-	}
 }
 
 func (s *Strategy) PopulateSettingsMethod(r *http.Request, ss *session.Session, pr *settings.Request) error {
@@ -213,28 +159,22 @@ func (s *Strategy) PopulateSettingsMethod(r *http.Request, ss *session.Session,
 		Method: string(s.ID()),
 		Config: &settings.RequestMethodConfig{RequestMethodConfigurator: &RequestMethod{HTMLForm: f}},
 	}
-	pr.Active = sqlxx.NullString(s.ID())
 	return nil
 }
 
-func (s *Strategy) handleSettingsError(w http.ResponseWriter, r *http.Request, rr *settings.Request, ss *session.Session, p *completeSelfServiceBrowserSettingsPasswordFlowPayload, err error) {
+func (s *Strategy) handleSettingsError(w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *completeSelfServiceBrowserSettingsPasswordFlowPayload, err error) {
 	if errors.Is(err, settings.ErrRequestNeedsReAuthentication) {
-		if err := s.d.ContinuityManager().Pause(
-			r.Context(), w, r,
-			continuityKeySettings(r.URL.Query().Get("request")),
-			continuity.WithPayload(p),
-			continuity.WithIdentity(ss.Identity),
-			continuity.WithLifespan(time.Minute*15),
-		); err != nil {
-			s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, rr, err, s.SettingsStrategyID())
+		if err := s.d.ContinuityManager().Pause(r.Context(), w, r,
+			settings.ContinuityKey(s.SettingsStrategyID()), settings.ContinuityOptions(p, ctxUpdate.Session.Identity)...); err != nil {
+			s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ctxUpdate.Request, err, s.SettingsStrategyID())
 			return
 		}
 	}
 
-	if rr != nil {
-		rr.Methods[s.SettingsStrategyID()].Config.Reset()
-		rr.Methods[s.SettingsStrategyID()].Config.SetCSRF(s.d.GenerateCSRFToken(r))
+	if ctxUpdate.Request != nil {
+		ctxUpdate.Request.Methods[s.SettingsStrategyID()].Config.Reset()
+		ctxUpdate.Request.Methods[s.SettingsStrategyID()].Config.SetCSRF(s.d.GenerateCSRFToken(r))
 	}
 
-	s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, rr, err, s.SettingsStrategyID())
+	s.d.SettingsRequestErrorHandler().HandleSettingsError(w, r, ctxUpdate.Request, err, s.SettingsStrategyID())
 }
diff --git a/selfservice/strategy/password/strategy.go b/selfservice/strategy/password/strategy.go
index eee47c165..637378604 100644
--- a/selfservice/strategy/password/strategy.go
+++ b/selfservice/strategy/password/strategy.go
@@ -1,6 +1,10 @@
 package password
 
 import (
+	"encoding/json"
+	"strings"
+
+	"github.com/pkg/errors"
 	"gopkg.in/go-playground/validator.v9"
 
 	"github.com/ory/kratos/continuity"
@@ -16,6 +20,7 @@ import (
 
 var _ login.Strategy = new(Strategy)
 var _ registration.Strategy = new(Strategy)
+var _ identity.ActiveCredentialsCounter = new(Strategy)
 
 type registrationStrategyDependencies interface {
 	x.LoggingProvider
@@ -58,6 +63,23 @@ type Strategy struct {
 	v *validator.Validate
 }
 
+func (s *Strategy) CountActiveCredentials(cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
+	for _, c := range cc {
+		if c.Type == s.ID() && len(c.Config) > 0 {
+			var conf CredentialsConfig
+			if err = json.Unmarshal(c.Config, &conf); err != nil {
+				return 0, errors.WithStack(err)
+			}
+
+			if len(c.Identifiers) > 0 && len(c.Identifiers[0]) > 0 &&
+				strings.HasPrefix(conf.HashedPassword, "$argon2id$") {
+				count++
+			}
+		}
+	}
+	return
+}
+
 func NewStrategy(
 	d registrationStrategyDependencies,
 	c configuration.Provider,
diff --git a/session/session.go b/session/session.go
index 73c5071f5..532f2a75f 100644
--- a/session/session.go
+++ b/session/session.go
@@ -47,6 +47,7 @@ func NewSession(i *identity.Identity, c interface {
 		AuthenticatedAt: authenticatedAt,
 		IssuedAt:        time.Now().UTC(),
 		Identity:        i,
+		IdentityID:      i.ID,
 	}
 }
 
diff --git a/cypress/fixtures/example.json b/test/e2e/cypress/fixtures/example.json
similarity index 100%
rename from cypress/fixtures/example.json
rename to test/e2e/cypress/fixtures/example.json
diff --git a/cypress/helpers/index.js b/test/e2e/cypress/helpers/index.js
similarity index 100%
rename from cypress/helpers/index.js
rename to test/e2e/cypress/helpers/index.js
diff --git a/cypress/integration/profiles/email/login/error.spec.js b/test/e2e/cypress/integration/profiles/email/login/error.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/login/error.spec.js
rename to test/e2e/cypress/integration/profiles/email/login/error.spec.js
diff --git a/cypress/integration/profiles/email/login/success.spec.js b/test/e2e/cypress/integration/profiles/email/login/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/login/success.spec.js
rename to test/e2e/cypress/integration/profiles/email/login/success.spec.js
diff --git a/cypress/integration/profiles/email/login/ui.spec.js b/test/e2e/cypress/integration/profiles/email/login/ui.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/login/ui.spec.js
rename to test/e2e/cypress/integration/profiles/email/login/ui.spec.js
diff --git a/cypress/integration/profiles/email/logout/success.spec.js b/test/e2e/cypress/integration/profiles/email/logout/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/logout/success.spec.js
rename to test/e2e/cypress/integration/profiles/email/logout/success.spec.js
diff --git a/cypress/integration/profiles/email/registration/errors.spec.js b/test/e2e/cypress/integration/profiles/email/registration/errors.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/registration/errors.spec.js
rename to test/e2e/cypress/integration/profiles/email/registration/errors.spec.js
diff --git a/cypress/integration/profiles/email/registration/success.spec.js b/test/e2e/cypress/integration/profiles/email/registration/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/registration/success.spec.js
rename to test/e2e/cypress/integration/profiles/email/registration/success.spec.js
diff --git a/cypress/integration/profiles/email/registration/ui.spec.js b/test/e2e/cypress/integration/profiles/email/registration/ui.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/registration/ui.spec.js
rename to test/e2e/cypress/integration/profiles/email/registration/ui.spec.js
diff --git a/cypress/integration/profiles/email/settings/errors.spec.js b/test/e2e/cypress/integration/profiles/email/settings/errors.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/settings/errors.spec.js
rename to test/e2e/cypress/integration/profiles/email/settings/errors.spec.js
diff --git a/cypress/integration/profiles/email/settings/success.spec.js b/test/e2e/cypress/integration/profiles/email/settings/success.spec.js
similarity index 97%
rename from cypress/integration/profiles/email/settings/success.spec.js
rename to test/e2e/cypress/integration/profiles/email/settings/success.spec.js
index 1652d2743..49214590c 100644
--- a/cypress/integration/profiles/email/settings/success.spec.js
+++ b/test/e2e/cypress/integration/profiles/email/settings/success.spec.js
@@ -1,10 +1,4 @@
-import {
-  APP_URL,
-  gen,
-  password,
-  privilegedLifespan,
-  website,
-} from '../../../../helpers'
+import { APP_URL, gen, website } from '../../../../helpers'
 
 context('Settings', () => {
   let email = gen.email()
diff --git a/cypress/integration/profiles/email/settings/ui.spec.js b/test/e2e/cypress/integration/profiles/email/settings/ui.spec.js
similarity index 100%
rename from cypress/integration/profiles/email/settings/ui.spec.js
rename to test/e2e/cypress/integration/profiles/email/settings/ui.spec.js
diff --git a/cypress/integration/profiles/oidc/login/error.spec.js b/test/e2e/cypress/integration/profiles/oidc/login/error.spec.js
similarity index 100%
rename from cypress/integration/profiles/oidc/login/error.spec.js
rename to test/e2e/cypress/integration/profiles/oidc/login/error.spec.js
diff --git a/cypress/integration/profiles/oidc/login/success.spec.js b/test/e2e/cypress/integration/profiles/oidc/login/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/oidc/login/success.spec.js
rename to test/e2e/cypress/integration/profiles/oidc/login/success.spec.js
diff --git a/cypress/integration/profiles/oidc/logout/success.spec.js b/test/e2e/cypress/integration/profiles/oidc/logout/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/oidc/logout/success.spec.js
rename to test/e2e/cypress/integration/profiles/oidc/logout/success.spec.js
diff --git a/cypress/integration/profiles/oidc/registration/error.spec.js b/test/e2e/cypress/integration/profiles/oidc/registration/error.spec.js
similarity index 100%
rename from cypress/integration/profiles/oidc/registration/error.spec.js
rename to test/e2e/cypress/integration/profiles/oidc/registration/error.spec.js
diff --git a/cypress/integration/profiles/oidc/registration/success.spec.js b/test/e2e/cypress/integration/profiles/oidc/registration/success.spec.js
similarity index 98%
rename from cypress/integration/profiles/oidc/registration/success.spec.js
rename to test/e2e/cypress/integration/profiles/oidc/registration/success.spec.js
index 561bdec42..3f7d715ad 100644
--- a/cypress/integration/profiles/oidc/registration/success.spec.js
+++ b/test/e2e/cypress/integration/profiles/oidc/registration/success.spec.js
@@ -36,6 +36,7 @@ context('Register', () => {
       'contain.text',
       'missing properties: "website"'
     )
+    cy.get('button[name="provider"]').should('have.length', 1)
     cy.get('#registration-oidc input[name="traits.website"]').type('http://s')
 
     cy.get('button[value="hydra"]').click()
diff --git a/cypress/integration/profiles/verify/registration/errors.spec.js b/test/e2e/cypress/integration/profiles/verify/registration/errors.spec.js
similarity index 100%
rename from cypress/integration/profiles/verify/registration/errors.spec.js
rename to test/e2e/cypress/integration/profiles/verify/registration/errors.spec.js
diff --git a/cypress/integration/profiles/verify/registration/success.spec.js b/test/e2e/cypress/integration/profiles/verify/registration/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/verify/registration/success.spec.js
rename to test/e2e/cypress/integration/profiles/verify/registration/success.spec.js
diff --git a/cypress/integration/profiles/verify/settings/error.spec.js b/test/e2e/cypress/integration/profiles/verify/settings/error.spec.js
similarity index 100%
rename from cypress/integration/profiles/verify/settings/error.spec.js
rename to test/e2e/cypress/integration/profiles/verify/settings/error.spec.js
diff --git a/cypress/integration/profiles/verify/settings/success.spec.js b/test/e2e/cypress/integration/profiles/verify/settings/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/verify/settings/success.spec.js
rename to test/e2e/cypress/integration/profiles/verify/settings/success.spec.js
diff --git a/cypress/integration/profiles/verify/verify/errors.spec.js b/test/e2e/cypress/integration/profiles/verify/verify/errors.spec.js
similarity index 100%
rename from cypress/integration/profiles/verify/verify/errors.spec.js
rename to test/e2e/cypress/integration/profiles/verify/verify/errors.spec.js
diff --git a/cypress/integration/profiles/verify/verify/success.spec.js b/test/e2e/cypress/integration/profiles/verify/verify/success.spec.js
similarity index 100%
rename from cypress/integration/profiles/verify/verify/success.spec.js
rename to test/e2e/cypress/integration/profiles/verify/verify/success.spec.js
diff --git a/cypress/plugins/index.js b/test/e2e/cypress/plugins/index.js
similarity index 100%
rename from cypress/plugins/index.js
rename to test/e2e/cypress/plugins/index.js
diff --git a/cypress/support/commands.js b/test/e2e/cypress/support/commands.js
similarity index 98%
rename from cypress/support/commands.js
rename to test/e2e/cypress/support/commands.js
index 5cd3f5b16..9d277df09 100644
--- a/cypress/support/commands.js
+++ b/test/e2e/cypress/support/commands.js
@@ -124,6 +124,11 @@ Cypress.Commands.add('login', ({ email, password, expectSession = true }) => {
   }
 })
 
+Cypress.Commands.add('logout', () => {
+  cy.get('.logout a').click()
+  cy.noSession()
+})
+
 Cypress.Commands.add(
   'reauth',
   ({
diff --git a/x/cookie.go b/x/cookie.go
index 94ed102e4..9aa29d971 100644
--- a/x/cookie.go
+++ b/x/cookie.go
@@ -58,3 +58,13 @@ func SessionUnset(w http.ResponseWriter, r *http.Request, s sessions.Store, id s
 	cookie.Options.MaxAge = -1
 	return errors.WithStack(cookie.Save(r, w))
 }
+
+func SessionUnsetKey(w http.ResponseWriter, r *http.Request, s sessions.Store, id, key string) error {
+	cookie, err := s.Get(r, id)
+	if err != nil {
+		return nil
+	}
+
+	delete(cookie.Values, key)
+	return errors.WithStack(cookie.Save(r, w))
+}
diff --git a/x/err.go b/x/err.go
new file mode 100644
index 000000000..97d660eb4
--- /dev/null
+++ b/x/err.go
@@ -0,0 +1,14 @@
+package x
+
+import (
+	"net/http"
+
+	"github.com/ory/herodot"
+)
+
+var PseudoPanic = herodot.DefaultError{
+	StatusField: http.StatusText(http.StatusInternalServerError),
+	ErrorField:  "Code Bug Detected",
+	ReasonField: "The code ended up at a place where it should not have. Please report this as an issue at https://github.com/ory/kratos",
+	CodeField:   http.StatusConflict,
+}


Test patch:
diff --git a/continuity/manager_test.go b/continuity/manager_test.go
index 124a37b86..3e9961310 100644
--- a/continuity/manager_test.go
+++ b/continuity/manager_test.go
@@ -61,7 +61,7 @@ func TestManager(t *testing.T) {
 				return
 			}
 
-			c, err := p.Continue(r.Context(), r, ps.ByName("name"), tc.wo...)
+			c, err := p.Continue(r.Context(), w, r, ps.ByName("name"), tc.wo...)
 			if err != nil {
 				writer.WriteError(w, r, err)
 				return
@@ -70,7 +70,7 @@ func TestManager(t *testing.T) {
 		})
 
 		router.GET("/:name", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
-			c, err := p.Continue(r.Context(), r, ps.ByName("name"), tc.ro...)
+			c, err := p.Continue(r.Context(), w, r, ps.ByName("name"), tc.ro...)
 			if err != nil {
 				writer.WriteError(w, r, err)
 				return
diff --git a/internal/testhelpers/errorx.go b/internal/testhelpers/errorx.go
index cd8ef6d11..454d3808f 100644
--- a/internal/testhelpers/errorx.go
+++ b/internal/testhelpers/errorx.go
@@ -19,11 +19,12 @@ import (
 
 func NewErrorTestServer(t *testing.T, reg interface{ errorx.PersistenceProvider }) *httptest.Server {
 	logger := logrus.New()
+	logger.Level = logrus.TraceLevel
 	writer := herodot.NewJSONWriter(logger)
 	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		e, err := reg.SelfServiceErrorPersister().Read(r.Context(), x.ParseUUID(r.URL.Query().Get("error")))
 		require.NoError(t, err)
-		logger.Errorf("Found error in NewErrorTestServer: %s", e.Errors)
+		t.Logf("Found error in NewErrorTestServer: %s", e.Errors)
 		writer.Write(w, r, e.Errors)
 	}))
 	t.Cleanup(ts.Close)
diff --git a/internal/testhelpers/handler_mock.go b/internal/testhelpers/handler_mock.go
index 075e75d10..9047f77cb 100644
--- a/internal/testhelpers/handler_mock.go
+++ b/internal/testhelpers/handler_mock.go
@@ -96,7 +96,9 @@ func MockSessionCreateHandlerWithIdentity(t *testing.T, reg mockDeps, i *identit
 	var sess session.Session
 	require.NoError(t, faker.FakeData(&sess))
 	// require AuthenticatedAt to be time.Now() as we always compare it to the current time
-	sess.AuthenticatedAt = time.Now()
+	sess.AuthenticatedAt = time.Now().UTC()
+	sess.IssuedAt = time.Now().UTC()
+	sess.ExpiresAt = time.Now().UTC().Add(time.Hour * 24)
 
 	if viper.GetString(configuration.ViperKeyDefaultIdentityTraitsSchemaURL) == "" {
 		viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/fake-session.schema.json")
diff --git a/internal/testhelpers/http.go b/internal/testhelpers/http.go
new file mode 100644
index 000000000..75930db4b
--- /dev/null
+++ b/internal/testhelpers/http.go
@@ -0,0 +1,46 @@
+package testhelpers
+
+import (
+	"bytes"
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func HTTPRequestJSON(t *testing.T, client *http.Client, method string, url string, in interface{}) ([]byte, *http.Response) {
+	var body bytes.Buffer
+	require.NoError(t, json.NewEncoder(&body).Encode(in))
+
+	req, err := http.NewRequest(method, url, &body)
+	require.NoError(t, err)
+
+	req.Header.Set("Content-Type", "application/json")
+
+	res, err := client.Do(req)
+	require.NoError(t, err)
+	defer res.Body.Close()
+
+	payload, err := ioutil.ReadAll(res.Body)
+	require.NoError(t, err)
+
+	return payload, res
+}
+
+func HTTPPostForm(t *testing.T, client *http.Client, remote string, in *url.Values) ([]byte, *http.Response) {
+	if in == nil {
+		in = new(url.Values)
+	}
+
+	res, err := client.PostForm(remote, *in)
+	require.NoError(t, err)
+	defer res.Body.Close()
+
+	payload, err := ioutil.ReadAll(res.Body)
+	require.NoError(t, err)
+
+	return payload, res
+}
diff --git a/internal/testhelpers/json.go b/internal/testhelpers/json.go
new file mode 100644
index 000000000..a0a59ce4a
--- /dev/null
+++ b/internal/testhelpers/json.go
@@ -0,0 +1,17 @@
+package testhelpers
+
+import (
+	"bytes"
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func JSONEq(t *testing.T, expected, actual interface{}, messageAndArgs ...interface{}) {
+	var eb, ab bytes.Buffer
+	require.NoError(t, json.NewEncoder(&eb).Encode(expected))
+	require.NoError(t, json.NewEncoder(&ab).Encode(actual))
+	assert.JSONEq(t, eb.String(), ab.String(), messageAndArgs...)
+}
diff --git a/internal/testhelpers/selfservice_settings.go b/internal/testhelpers/selfservice_settings.go
index c3144be5e..66b2ca2dd 100644
--- a/internal/testhelpers/selfservice_settings.go
+++ b/internal/testhelpers/selfservice_settings.go
@@ -2,12 +2,12 @@ package testhelpers
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/url"
-	"strconv"
 	"testing"
 
 	"github.com/gobuffalo/httptest"
@@ -65,6 +65,7 @@ func GetSettingsRequest(t *testing.T, primaryUser *http.Client, ts *httptest.Ser
 			WithRequest(res.Request.URL.Query().Get("request")),
 	)
 	require.NoError(t, err)
+	assert.Empty(t, rs.Payload.Active)
 
 	return rs
 }
@@ -119,7 +120,7 @@ func NewSettingsLoginAcceptAPIServer(t *testing.T, adminClient *client.OryKratos
 	return loginTS
 }
 
-func NewSettingsAPIServer(t *testing.T, reg *driver.RegistryDefault, ids []identity.Identity) (*httptest.Server, *httptest.Server) {
+func NewSettingsAPIServer(t *testing.T, reg *driver.RegistryDefault, ids map[string]*identity.Identity) (*httptest.Server, *httptest.Server, map[string]*http.Client) {
 	public, admin := x.NewRouterPublic(), x.NewRouterAdmin()
 	reg.SettingsHandler().RegisterAdminRoutes(admin)
 
@@ -140,13 +141,32 @@ func NewSettingsAPIServer(t *testing.T, reg *driver.RegistryDefault, ids []ident
 
 	viper.Set(configuration.ViperKeyURLsSelfPublic, tsp.URL)
 	viper.Set(configuration.ViperKeyURLsSelfAdmin, tsa.URL)
+	return tsp, tsa, AddAndLoginIdentities(t, reg, &httptest.Server{Config: &http.Server{Handler: public}, URL: tsp.URL}, ids)
+}
 
+// AddAndLoginIdentities adds the given identities to the store (like a registration flow) and returns http.Clients
+// which contain their sessions.
+func AddAndLoginIdentities(t *testing.T, reg *driver.RegistryDefault, public *httptest.Server, ids map[string]*identity.Identity) map[string]*http.Client {
+	result := map[string]*http.Client{}
 	for k := range ids {
-		route, _ := MockSessionCreateHandlerWithIdentity(t, reg, &ids[k])
-		public.GET("/sessions/set/"+strconv.Itoa(k), route)
+		tid := x.NewUUID().String()
+		_ = reg.PrivilegedIdentityPool().DeleteIdentity(context.Background(), ids[k].ID)
+		route, _ := MockSessionCreateHandlerWithIdentity(t, reg, ids[k])
+		location := "/sessions/set/" + tid
+
+		if router, ok := public.Config.Handler.(*x.RouterPublic); ok {
+			router.Router.GET(location, route)
+		} else if router, ok := public.Config.Handler.(*httprouter.Router); ok {
+			router.GET(location, route)
+		} else if router, ok := public.Config.Handler.(*x.RouterAdmin); ok {
+			router.GET(location, route)
+		} else {
+			t.Logf("Got unknown type: %T", public.Config.Handler)
+			t.FailNow()
+		}
+		result[k] = NewSessionClient(t, public.URL+location)
 	}
-
-	return tsp, tsa
+	return result
 }
 
 func SettingsSubmitForm(
diff --git a/internal/testhelpers/server.go b/internal/testhelpers/server.go
new file mode 100644
index 000000000..e2a31b738
--- /dev/null
+++ b/internal/testhelpers/server.go
@@ -0,0 +1,31 @@
+package testhelpers
+
+import (
+	"testing"
+
+	"github.com/gobuffalo/httptest"
+
+	"github.com/ory/viper"
+
+	"github.com/ory/kratos/driver"
+	"github.com/ory/kratos/driver/configuration"
+	"github.com/ory/kratos/x"
+)
+
+func NewKratosServer(t *testing.T, reg driver.Registry) (public, admin *httptest.Server) {
+	rp := x.NewRouterPublic()
+	ra := x.NewRouterAdmin()
+
+	public = httptest.NewServer(rp)
+	admin = httptest.NewServer(ra)
+
+	viper.Set(configuration.ViperKeyURLsSelfPublic, public.URL)
+	viper.Set(configuration.ViperKeyURLsSelfAdmin, admin.URL)
+
+	reg.RegisterRoutes(rp, ra)
+
+	t.Cleanup(public.Close)
+	t.Cleanup(admin.Close)
+
+	return
+}
diff --git a/script/test-envs.sh b/script/test-envs.sh
old mode 100644
new mode 100755
index fce33c29d..fb6b8d32e
--- a/script/test-envs.sh
+++ b/script/test-envs.sh
@@ -1,5 +1,8 @@
 #! /bin/bash
 
-export TEST_DATABASE_MYSQL='mysql://root:secret@(127.0.0.1:3444)/mysql?parseTime=true&multiStatements=true'
-export TEST_DATABASE_POSTGRESQL='postgres://postgres:secret@127.0.0.1:3445/postgres?sslmode=disable'
-export TEST_DATABASE_COCKROACHDB='cockroach://root@127.0.0.1:3446/defaultdb?sslmode=disable'
+export TEST_DATABASE_MYSQL="mysql://root:secret@(127.0.0.1:3444)/mysql?parseTime=true&multiStatements=true"
+export TEST_DATABASE_POSTGRESQL="postgres://postgres:secret@127.0.0.1:3445/postgres?sslmode=disable"
+export TEST_DATABASE_COCKROACHDB="cockroach://root@127.0.0.1:3446/defaultdb?sslmode=disable"
+export TEST_SELFSERVICE_OIDC_HYDRA_ADMIN=http://127.0.0.1:4445
+export TEST_SELFSERVICE_OIDC_HYDRA_PUBLIC=http://127.0.0.1:4444
+export TEST_SELFSERVICE_OIDC_HYDRA_INTEGRATION_ADDR=http://127.0.0.1:4446
diff --git a/script/testenv.sh b/script/testenv.sh
new file mode 100755
index 000000000..7239e8e2a
--- /dev/null
+++ b/script/testenv.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+docker rm -f kratos_test_database_mysql kratos_test_database_postgres kratos_test_database_cockroach kratos_test_hydra || true
+docker run --rm --name kratos_test_database_mysql -p 3444:3306 -e MYSQL_ROOT_PASSWORD=secret -d mysql:5.7
+docker run --rm --name kratos_test_database_postgres -p 3445:5432 -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=postgres -d postgres:9.6
+docker run --rm --name kratos_test_database_cockroach -p 3446:26257 -d cockroachdb/cockroach:v2.1.6 start --insecure
+docker run --rm --name kratos_test_hydra -p 4444:4444 -p 4445:4445 -d -e DSN=memory -e URLS_SELF_ISSUER=http://127.0.0.1:4444 -e URLS_LOGIN=http://127.0.0.1:4446/login -e URLS_CONSENT=http://127.0.0.1:4446/consent oryd/hydra:v1.4.10 serve all --dangerous-force-http
+
+source script/test-envs.sh
diff --git a/selfservice/flow/settings/handler_test.go b/selfservice/flow/settings/handler_test.go
index abf007969..b61ef96fd 100644
--- a/selfservice/flow/settings/handler_test.go
+++ b/selfservice/flow/settings/handler_test.go
@@ -39,11 +39,11 @@ func TestHandler(t *testing.T) {
 
 	viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, "1ns")
 	primaryIdentity := &identity.Identity{ID: x.NewUUID(), Traits: identity.Traits(`{}`)}
-	publicTS, adminTS := testhelpers.NewSettingsAPIServer(t, reg, []identity.Identity{
-		*primaryIdentity, {ID: x.NewUUID(), Traits: identity.Traits(`{}`)}})
+	publicTS, adminTS, clients := testhelpers.NewSettingsAPIServer(t, reg, map[string]*identity.Identity{
+		"primary":   primaryIdentity,
+		"secondary": {ID: x.NewUUID(), Traits: identity.Traits(`{}`)}})
 
-	primaryUser, otherUser := testhelpers.NewSessionClient(t, publicTS.URL+"/sessions/set/0"),
-		testhelpers.NewSessionClient(t, publicTS.URL+"/sessions/set/1")
+	primaryUser, otherUser := clients["primary"], clients["secondary"]
 	publicClient, adminClient := testhelpers.NewSDKClient(publicTS), testhelpers.NewSDKClient(adminTS)
 	newExpiredRequest := func() *settings.Request {
 		return settings.NewRequest(-time.Minute,
diff --git a/selfservice/flow/settings/hook_test.go b/selfservice/flow/settings/hook_test.go
index 1c5d6e240..8c7061e37 100644
--- a/selfservice/flow/settings/hook_test.go
+++ b/selfservice/flow/settings/hook_test.go
@@ -51,7 +51,7 @@ func TestSettingsExecutor(t *testing.T) {
 					a.RequestURL = x.RequestURL(r).String()
 					require.NoError(t, reg.SettingsRequestPersister().CreateSettingsRequest(r.Context(), a))
 					_ = handleErr(t, w, r, reg.SettingsHookExecutor().
-						PostSettingsHook(w, r, strategy, a, sess, i))
+						PostSettingsHook(w, r, strategy, &settings.UpdateContext{Request: a, Session: sess}, i))
 				})
 				ts := httptest.NewServer(router)
 				t.Cleanup(ts.Close)
diff --git a/selfservice/flow/settings/strategy_profile_test.go b/selfservice/flow/settings/strategy_profile_test.go
index 1e438be93..07dc228af 100644
--- a/selfservice/flow/settings/strategy_profile_test.go
+++ b/selfservice/flow/settings/strategy_profile_test.go
@@ -37,7 +37,6 @@ func TestStrategyTraits(t *testing.T) {
 	_, reg := internal.NewFastRegistryWithMocks(t)
 	viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/identity.schema.json")
 	viper.Set(configuration.ViperKeyURLsDefaultReturnTo, "https://www.ory.sh/")
-	viper.Set(configuration.ViperKeyURLsDefaultReturnTo, "https://www.ory.sh/")
 
 	ui := testhelpers.NewSettingsUITestServer(t)
 	viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, "1ns")
@@ -51,11 +50,13 @@ func TestStrategyTraits(t *testing.T) {
 		},
 		Traits:         identity.Traits(`{"email":"john@doe.com","stringy":"foobar","booly":false,"numby":2.5,"should_long_string":"asdfasdfasdfasdfasfdasdfasdfasdf","should_big_number":2048}`),
 		TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID,
+		Addresses:      []identity.VerifiableAddress{{Value: "john@doe.com", Via: identity.VerifiableAddressTypeEmail}},
 	}
-	publicTS, adminTS := testhelpers.NewSettingsAPIServer(t, reg, []identity.Identity{
-		*primaryIdentity, {ID: x.NewUUID(), Traits: identity.Traits(`{}`)}})
+	publicTS, adminTS, clients := testhelpers.NewSettingsAPIServer(t, reg, map[string]*identity.Identity{
+		"primary":   primaryIdentity,
+		"secondary": {ID: x.NewUUID(), Traits: identity.Traits(`{}`)}})
 
-	primaryUser := testhelpers.NewSessionClient(t, publicTS.URL+"/sessions/set/0")
+	primaryUser := clients["primary"]
 	publicClient := testhelpers.NewSDKClient(publicTS)
 	adminClient := testhelpers.NewSDKClient(adminTS)
 
@@ -133,12 +134,12 @@ func TestStrategyTraits(t *testing.T) {
 				Action: pointerx.String(publicTS.URL + settings.PublicSettingsProfilePath + "?request=" + rid),
 				Method: pointerx.String("POST"),
 				Fields: models.FormFields{
-					&models.FormField{Required: pointerx.Bool(false), Name: pointerx.String("traits.email"), Type: pointerx.String("text"), Value: "john@doe.com"},
-					&models.FormField{Required: pointerx.Bool(false), Name: pointerx.String("traits.stringy"), Type: pointerx.String("text"), Value: "foobar"},
-					&models.FormField{Required: pointerx.Bool(false), Name: pointerx.String("traits.numby"), Type: pointerx.String("number"), Value: json.Number("2.5")},
-					&models.FormField{Required: pointerx.Bool(false), Name: pointerx.String("traits.booly"), Type: pointerx.String("checkbox"), Value: false},
-					&models.FormField{Required: pointerx.Bool(false), Name: pointerx.String("traits.should_big_number"), Type: pointerx.String("number"), Value: json.Number("2048")},
-					&models.FormField{Required: pointerx.Bool(false), Name: pointerx.String("traits.should_long_string"), Type: pointerx.String("text"), Value: "asdfasdfasdfasdfasfdasdfasdfasdf"},
+					&models.FormField{Name: pointerx.String("traits.email"), Type: pointerx.String("text"), Value: "john@doe.com"},
+					&models.FormField{Name: pointerx.String("traits.stringy"), Type: pointerx.String("text"), Value: "foobar"},
+					&models.FormField{Name: pointerx.String("traits.numby"), Type: pointerx.String("number"), Value: json.Number("2.5")},
+					&models.FormField{Name: pointerx.String("traits.booly"), Type: pointerx.String("checkbox"), Value: false},
+					&models.FormField{Name: pointerx.String("traits.should_big_number"), Type: pointerx.String("number"), Value: json.Number("2048")},
+					&models.FormField{Name: pointerx.String("traits.should_long_string"), Type: pointerx.String("text"), Value: "asdfasdfasdfasdfasfdasdfasdfasdf"},
 				},
 			}, f)
 		})
@@ -159,6 +160,7 @@ func TestStrategyTraits(t *testing.T) {
 		})
 
 		t.Run("description=should update protected field with sudo mode", func(t *testing.T) {
+			viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, "5m")
 			_ = testhelpers.NewSettingsLoginAcceptAPIServer(t, adminClient)
 			t.Cleanup(func() {
 				viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, "1ns")
diff --git a/selfservice/strategy/oidc/strategy_helper_test.go b/selfservice/strategy/oidc/strategy_helper_test.go
index c884d7b20..034cbe83e 100644
--- a/selfservice/strategy/oidc/strategy_helper_test.go
+++ b/selfservice/strategy/oidc/strategy_helper_test.go
@@ -6,33 +6,37 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"net/http/cookiejar"
 	"net/http/httptest"
+	"net/url"
+	"os"
 	"testing"
 	"time"
 
 	"github.com/julienschmidt/httprouter"
+	"github.com/phayes/freeport"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/tidwall/gjson"
 
+	"github.com/ory/dockertest/v3"
+	"github.com/ory/dockertest/v3/docker"
+	"github.com/ory/viper"
 	"github.com/ory/x/resilience"
 	"github.com/ory/x/urlx"
 
 	"github.com/ory/kratos/driver"
+	"github.com/ory/kratos/driver/configuration"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/selfservice/strategy/oidc"
 	"github.com/ory/kratos/x"
 )
 
-func newErrTs(t *testing.T, reg driver.Registry) *httptest.Server {
-	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		e, err := reg.SelfServiceErrorPersister().Read(r.Context(), x.ParseUUID(r.URL.Query().Get("error")))
-		require.NoError(t, err)
-		reg.Writer().Write(w, r, e.Errors)
-	}))
-}
-
-func createClient(t *testing.T, remote string, redir string) {
+func createClient(t *testing.T, remote string, redir, id string) {
 	require.NoError(t, resilience.Retry(logrus.New(), time.Second*10, time.Minute*2, func() error {
-		if req, err := http.NewRequest("DELETE", remote+"/clients/client", nil); err != nil {
+		if req, err := http.NewRequest("DELETE", remote+"/clients/"+id, nil); err != nil {
 			return err
 		} else if _, err := http.DefaultClient.Do(req); err != nil {
 			return err
@@ -47,7 +51,7 @@ func createClient(t *testing.T, remote string, redir string) {
 			ResponseTypes []string `json:"response_types"`
 			RedirectURIs  []string `json:"redirect_uris"`
 		}{
-			ClientID:      "client",
+			ClientID:      id,
 			ClientSecret:  "secret",
 			GrantTypes:    []string{"authorization_code", "refresh_token"},
 			ResponseTypes: []string{"code"},
@@ -125,24 +129,161 @@ func newHydraIntegration(t *testing.T, remote *string, subject *string, scope *[
 
 	if addr == "" {
 		server := httptest.NewServer(router)
+		t.Cleanup(server.Close)
 		return server.Config, server.URL
 	}
-	server := &http.Server{Addr: addr, Handler: router}
-	go func() {
-		err := server.ListenAndServe()
-		if err == http.ErrServerClosed {
-		} else if err != nil {
-			panic(err)
+
+	parsed, err := url.ParseRequestURI(addr)
+	require.NoError(t, err)
+
+	server := &http.Server{Addr: ":" + parsed.Port(), Handler: router}
+	go func(t *testing.T) {
+		if err := server.ListenAndServe(); err != http.ErrServerClosed {
+			require.NoError(t, err)
+		} else if err == nil {
+			require.NoError(t, server.Close())
 		}
-	}()
-	return server, fmt.Sprintf("http://%s", addr)
+	}(t)
+	t.Cleanup(func() {
+		require.NoError(t, server.Close())
+	})
+	return server, addr
 }
 
 func newReturnTs(t *testing.T, reg driver.Registry) *httptest.Server {
-	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r)
 		require.NoError(t, err)
 		require.Empty(t, sess.Identity.Credentials)
 		reg.Writer().Write(w, r, sess)
 	}))
+	viper.Set(configuration.ViperKeyURLsDefaultReturnTo, ts.URL)
+	t.Cleanup(ts.Close)
+	return ts
+}
+
+func newUI(t *testing.T, reg driver.Registry) *httptest.Server {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var e interface{}
+		var err error
+		if r.URL.Path == "/login" {
+			e, err = reg.LoginRequestPersister().GetLoginRequest(r.Context(), x.ParseUUID(r.URL.Query().Get("request")))
+		} else if r.URL.Path == "/registration" {
+			e, err = reg.RegistrationRequestPersister().GetRegistrationRequest(r.Context(), x.ParseUUID(r.URL.Query().Get("request")))
+		} else if r.URL.Path == "/settings" {
+			e, err = reg.SettingsRequestPersister().GetSettingsRequest(r.Context(), x.ParseUUID(r.URL.Query().Get("request")))
+		}
+
+		require.NoError(t, err)
+		reg.Writer().Write(w, r, e)
+	}))
+	t.Cleanup(ts.Close)
+	viper.Set(configuration.ViperKeyURLsLogin, ts.URL+"/login")
+	viper.Set(configuration.ViperKeyURLsRegistration, ts.URL+"/registration")
+	viper.Set(configuration.ViperKeyURLsSettings, ts.URL+"/settings")
+	return ts
+}
+
+func newHydra(t *testing.T, subject *string, scope *[]string) (remoteAdmin, remotePublic, hydraIntegrationTSURL string) {
+	remoteAdmin = os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_ADMIN")
+	remotePublic = os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_PUBLIC")
+
+	hydraIntegrationTS, hydraIntegrationTSURL := newHydraIntegration(t, &remoteAdmin, subject, scope, os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_INTEGRATION_ADDR"))
+	t.Cleanup(func() {
+		require.NoError(t, hydraIntegrationTS.Close())
+	})
+
+	if remotePublic == "" && remoteAdmin == "" {
+		t.Logf("Environment did not provide ORY Hydra, starting fresh.")
+		publicPort, err := freeport.GetFreePort()
+		require.NoError(t, err)
+
+		pool, err := dockertest.NewPool("")
+		require.NoError(t, err)
+		hydra, err := pool.RunWithOptions(&dockertest.RunOptions{
+			Repository: "oryd/hydra",
+			Tag:        "v1.4.10",
+			Env: []string{
+				"DSN=memory",
+				fmt.Sprintf("URLS_SELF_ISSUER=http://127.0.0.1:%d/", publicPort),
+				"URLS_LOGIN=" + hydraIntegrationTSURL + "/login",
+				"URLS_CONSENT=" + hydraIntegrationTSURL + "/consent",
+			},
+			Cmd:          []string{"serve", "all", "--dangerous-force-http"},
+			ExposedPorts: []string{"4444/tcp", "4445/tcp"},
+			PortBindings: map[docker.Port][]docker.PortBinding{
+				"4444/tcp": {{HostPort: fmt.Sprintf("%d/tcp", publicPort)}},
+			},
+		})
+		require.NoError(t, err)
+		t.Cleanup(func() {
+			require.NoError(t, hydra.Close())
+		})
+		require.NoError(t, hydra.Expire(uint(60*5)))
+
+		require.NotEmpty(t, hydra.GetPort("4444/tcp"), "%+v", hydra.Container.NetworkSettings.Ports)
+		require.NotEmpty(t, hydra.GetPort("4445/tcp"), "%+v", hydra.Container)
+
+		remotePublic = "http://127.0.0.1:" + hydra.GetPort("4444/tcp")
+		remoteAdmin = "http://127.0.0.1:" + hydra.GetPort("4445/tcp")
+	}
+
+	t.Logf("ORY Hydra running at: %s %s", remotePublic, remoteAdmin)
+
+	return remoteAdmin, remotePublic, hydraIntegrationTSURL
+}
+
+func newOIDCProvider(
+	t *testing.T,
+	kratos *httptest.Server,
+	hydraPublic string,
+	hydraAdmin string,
+	id, clientID string,
+) oidc.Configuration {
+	createClient(t, hydraAdmin, kratos.URL+oidc.BasePath+"/callback/"+id, clientID)
+
+	return oidc.Configuration{
+		Provider:     "generic",
+		ID:           id,
+		ClientID:     clientID,
+		ClientSecret: "secret",
+		IssuerURL:    hydraPublic + "/",
+		Mapper:       "file://./stub/oidc.hydra.jsonnet",
+	}
+}
+
+func viperSetProviderConfig(providers ...oidc.Configuration) {
+	viper.Set(configuration.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC),
+		map[string]interface{}{"config": &oidc.ConfigurationCollection{Providers: providers}})
+}
+
+func newClient(t *testing.T, jar *cookiejar.Jar) *http.Client {
+	if jar == nil {
+		j, err := cookiejar.New(nil)
+		jar = j
+		require.NoError(t, err)
+	}
+	return &http.Client{
+		Jar: jar,
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			if debugRedirects {
+				t.Logf("Redirect: %s", req.URL.String())
+			}
+			if len(via) >= 20 {
+				for k, v := range via {
+					t.Logf("Failed with redirect (%d): %s", k, v.URL.String())
+				}
+				return errors.New("stopped after 20 redirects")
+			}
+			return nil
+		},
+	}
+}
+
+// AssertSystemError asserts an error ui response
+func AssertSystemError(t *testing.T, errTS *httptest.Server, res *http.Response, body []byte, code int, reason string) {
+	require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body)
+
+	assert.Equal(t, int64(code), gjson.GetBytes(body, "0.code").Int(), "%s", body)
+	assert.Contains(t, gjson.GetBytes(body, "0.reason").String(), reason, "%s", body)
 }
diff --git a/selfservice/strategy/oidc/strategy_settings_test.go b/selfservice/strategy/oidc/strategy_settings_test.go
new file mode 100644
index 000000000..1819abacd
--- /dev/null
+++ b/selfservice/strategy/oidc/strategy_settings_test.go
@@ -0,0 +1,634 @@
+package oidc_test
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/gofrs/uuid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/tidwall/gjson"
+
+	"github.com/ory/viper"
+	"github.com/ory/x/pointerx"
+
+	"github.com/ory/kratos/driver"
+	"github.com/ory/kratos/driver/configuration"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/internal"
+	"github.com/ory/kratos/internal/httpclient/client/common"
+	"github.com/ory/kratos/internal/httpclient/models"
+	"github.com/ory/kratos/internal/testhelpers"
+	"github.com/ory/kratos/selfservice/flow/settings"
+	"github.com/ory/kratos/selfservice/form"
+	"github.com/ory/kratos/selfservice/strategy/oidc"
+	"github.com/ory/kratos/session"
+	"github.com/ory/kratos/x"
+)
+
+func init() {
+	internal.RegisterFakes()
+}
+
+var (
+	csrfField = &models.FormField{Name: pointerx.String("csrf_token"), Value: "nosurf",
+		Required: true, Type: pointerx.String("hidden")}
+)
+
+func TestSettingsStrategy(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+
+	var (
+		_, reg  = internal.NewFastRegistryWithMocks(t)
+		subject string
+		scope   []string
+	)
+
+	remoteAdmin, remotePublic, _ := newHydra(t, &subject, &scope)
+	uiTS := newUI(t, reg)
+	errTS := testhelpers.NewErrorTestServer(t, reg)
+	publicTS, adminTS := testhelpers.NewKratosServer(t, reg)
+	public := testhelpers.NewSDKClient(publicTS)
+	admin := testhelpers.NewSDKClient(adminTS)
+
+	viperSetProviderConfig(
+		newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "ory", "ory"),
+		newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "google", "google"),
+		newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "github", "github"),
+	)
+	viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/settings.schema.json")
+	viper.Set(configuration.ViperKeyURLsDefaultReturnTo, "https://www.ory.sh/kratos")
+
+	// Make test data for this test run unique
+	testID := x.NewUUID().String()
+	users := map[string]*identity.Identity{
+		"password": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"john` + testID + `@doe.com"}`),
+			TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID,
+			Credentials: map[identity.CredentialsType]identity.Credentials{
+				"password": {Type: "password",
+					Identifiers: []string{"john+" + testID + "@doe.com"},
+					Config:      json.RawMessage(`{"hashed_password":"$argon2id$iammocked...."}`)}},
+		},
+		"oryer": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+` + testID + `@ory.sh"}`),
+			TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID,
+			Credentials: map[identity.CredentialsType]identity.Credentials{
+				identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC,
+					Identifiers: []string{"ory:hackerman+" + testID},
+					Config:      json.RawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+` + testID + `"}]}`)}},
+		},
+		"githuber": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+github+` + testID + `@ory.sh"}`),
+			Credentials: map[identity.CredentialsType]identity.Credentials{
+				identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC,
+					Identifiers: []string{"ory:hackerman+github+" + testID, "github:hackerman+github+" + testID},
+					Config:      json.RawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+github+` + testID + `"},{"provider":"github","subject":"hackerman+github+` + testID + `"}]}`)}},
+			TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID,
+		},
+		"multiuser": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+multiuser+` + testID + `@ory.sh"}`),
+			Credentials: map[identity.CredentialsType]identity.Credentials{
+				"password": {Type: "password",
+					Identifiers: []string{"hackerman+multiuser+" + testID + "@ory.sh"},
+					Config:      json.RawMessage(`{"hashed_password":"$argon2id$iammocked...."}`)},
+				identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC,
+					Identifiers: []string{"ory:hackerman+multiuser+" + testID, "google:hackerman+multiuser+" + testID},
+					Config:      json.RawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+multiuser+` + testID + `"},{"provider":"google","subject":"hackerman+multiuser+` + testID + `"}]}`)}},
+			TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID,
+		},
+	}
+	agents := testhelpers.AddAndLoginIdentities(t, reg, publicTS, users)
+
+	// new profile request
+	var npr = func(t *testing.T, client *http.Client, redirectTo string, exp time.Duration) *settings.Request {
+		req, err := reg.SettingsRequestPersister().GetSettingsRequest(context.Background(),
+			x.ParseUUID(string(testhelpers.GetSettingsRequest(t, client, publicTS).Payload.ID)))
+		require.NoError(t, err)
+		assert.Empty(t, req.Active)
+
+		if redirectTo != "" {
+			req.RequestURL = redirectTo
+		}
+		req.ExpiresAt = time.Now().Add(exp)
+		require.NoError(t, reg.SettingsRequestPersister().UpdateSettingsRequest(context.Background(), req))
+
+		// sanity check
+		got, err := reg.SettingsRequestPersister().GetSettingsRequest(context.Background(), req.ID)
+		require.NoError(t, err)
+		require.Len(t, got.Methods, len(req.Methods))
+
+		return req
+	}
+
+	// does the same as new profile request but uses the SDK
+	var nprSDK = func(t *testing.T, client *http.Client, redirectTo string, exp time.Duration) *models.SettingsRequest {
+		req := npr(t, client, redirectTo, exp)
+		rs, err := admin.Common.GetSelfServiceBrowserSettingsRequest(common.
+			NewGetSelfServiceBrowserSettingsRequestParams().WithHTTPClient(client).
+			WithRequest(req.ID.String()))
+		require.NoError(t, err)
+		return rs.Payload
+	}
+
+	t.Run("case=should not be able to continue a flow with a malformed ID", func(t *testing.T) {
+		body, res := testhelpers.HTTPPostForm(t, agents["password"], publicTS.URL+oidc.SettingsPath+"?request=i-am-not-a-uuid", nil)
+		AssertSystemError(t, errTS, res, body, 400, "malformed")
+	})
+
+	t.Run("case=should not be able to continue a flow without the request query parameter", func(t *testing.T) {
+		body, res := testhelpers.HTTPPostForm(t, agents["password"], publicTS.URL+oidc.SettingsPath, nil)
+		AssertSystemError(t, errTS, res, body, 400, "query parameter is missing")
+	})
+
+	t.Run("case=should not be able to continue a flow with a non-existing ID", func(t *testing.T) {
+		body, res := testhelpers.HTTPPostForm(t, agents["password"], publicTS.URL+oidc.SettingsPath+"?request="+x.NewUUID().String(), nil)
+		AssertSystemError(t, errTS, res, body, 404, "not be found")
+	})
+
+	t.Run("case=should not be able to continue a flow that is expired", func(t *testing.T) {
+		req := npr(t, agents["password"], "", -time.Hour)
+		body, res := testhelpers.HTTPPostForm(t, agents["password"], publicTS.URL+oidc.SettingsPath+"?request="+req.ID.String(), nil)
+		AssertSystemError(t, errTS, res, body, 400, "expired")
+	})
+
+	t.Run("case=should not be able to fetch another user's data", func(t *testing.T) {
+		req := npr(t, agents["password"], "", time.Hour)
+
+		_, err := public.Common.GetSelfServiceBrowserSettingsRequest(common.
+			NewGetSelfServiceBrowserSettingsRequestParams().WithHTTPClient(agents["oryer"]).
+			WithRequest(req.ID.String()))
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "403")
+	})
+
+	t.Run("case=should fetch the settings request and expect data to be set appropriately", func(t *testing.T) {
+		req := npr(t, agents["password"], "", time.Hour)
+
+		rs, err := admin.Common.GetSelfServiceBrowserSettingsRequest(common.
+			NewGetSelfServiceBrowserSettingsRequestParams().WithHTTPClient(agents["password"]).
+			WithRequest(req.ID.String()))
+		require.NoError(t, err)
+
+		// Check our sanity. Does the SDK relay the same info that we expect and got from the store?
+		assert.Equal(t, publicTS.URL+"/self-service/browser/flows/settings", req.RequestURL)
+		assert.Empty(t, req.Active)
+		assert.NotEmpty(t, req.IssuedAt)
+		assert.EqualValues(t, users["password"].ID, req.Identity.ID)
+		assert.EqualValues(t, users["password"].Traits, req.Identity.Traits)
+		assert.EqualValues(t, users["password"].TraitsSchemaID, req.Identity.TraitsSchemaID)
+
+		assert.EqualValues(t, req.ID.String(), rs.Payload.ID)
+		assert.EqualValues(t, req.RequestURL, *rs.Payload.RequestURL)
+		assert.EqualValues(t, req.Identity.ID.String(), rs.Payload.Identity.ID)
+		assert.EqualValues(t, req.IssuedAt, time.Time(*rs.Payload.IssuedAt))
+
+		require.NotNil(t, identity.CredentialsTypeOIDC.String(), rs.Payload.Methods[identity.CredentialsTypeOIDC.String()])
+		require.EqualValues(t, identity.CredentialsTypeOIDC.String(), rs.Payload.Methods[identity.CredentialsTypeOIDC.String()].Method)
+		require.EqualValues(t, publicTS.URL+oidc.SettingsPath+"?request="+req.ID.String(),
+			*rs.Payload.Methods[identity.CredentialsTypeOIDC.String()].Config.Action)
+	})
+
+	expectedOryerFields := models.FormFields{
+		{Type: pointerx.String("submit"), Name: pointerx.String("link"), Value: "google"},
+		{Type: pointerx.String("submit"), Name: pointerx.String("link"), Value: "github"}}
+	expectedGithuberFields := models.FormFields{
+		{Type: pointerx.String("submit"), Name: pointerx.String("link"), Value: "google"},
+		{Type: pointerx.String("submit"), Name: pointerx.String("unlink"), Value: "ory"},
+		{Type: pointerx.String("submit"), Name: pointerx.String("unlink"), Value: "github"}}
+	t.Run("case=should adjust linkable providers based on linked credentials", func(t *testing.T) {
+		for _, tc := range []struct {
+			agent    string
+			expected models.FormFields
+		}{
+			{agent: "password", expected: models.FormFields{
+				{Type: pointerx.String("submit"), Name: pointerx.String("link"), Value: "ory"},
+				{Type: pointerx.String("submit"), Name: pointerx.String("link"), Value: "google"},
+				{Type: pointerx.String("submit"), Name: pointerx.String("link"), Value: "github"}}},
+			{agent: "oryer", expected: expectedOryerFields},
+			{agent: "githuber", expected: expectedGithuberFields},
+			{agent: "multiuser", expected: models.FormFields{
+				{Type: pointerx.String("submit"), Name: pointerx.String("link"), Value: "github"},
+				{Type: pointerx.String("submit"), Name: pointerx.String("unlink"), Value: "ory"},
+				{Type: pointerx.String("submit"), Name: pointerx.String("unlink"), Value: "google"}}},
+		} {
+			t.Run("agent="+tc.agent, func(t *testing.T) {
+				rs := nprSDK(t, agents[tc.agent], "", time.Hour)
+				assert.EqualValues(t, append(models.FormFields{csrfField}, tc.expected...),
+					rs.Methods[identity.CredentialsTypeOIDC.String()].Config.Fields)
+			})
+		}
+	})
+
+	var action = func(req *models.SettingsRequest) string {
+		return *req.Methods[identity.CredentialsTypeOIDC.String()].Config.Action
+	}
+
+	var checkCredentials = func(t *testing.T, shouldExist bool, iid uuid.UUID, provider, subject string) {
+		actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), iid)
+		require.NoError(t, err)
+
+		var cc oidc.CredentialsConfig
+		creds, err := actual.ParseCredentials(identity.CredentialsTypeOIDC, &cc)
+		require.NoError(t, err)
+
+		if shouldExist {
+			assert.Contains(t, creds.Identifiers, provider+":"+subject)
+		} else {
+			assert.NotContains(t, creds.Identifiers, provider+":"+subject)
+		}
+
+		var found bool
+		for _, p := range cc.Providers {
+			if p.Provider == provider && p.Subject == subject {
+				found = true
+				break
+			}
+		}
+
+		require.EqualValues(t, shouldExist, found)
+	}
+
+	var reset = func(t *testing.T) func() {
+		return func() {
+			viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, time.Minute*5)
+			agents = testhelpers.AddAndLoginIdentities(t, reg, publicTS, users)
+		}
+	}
+
+	t.Run("suite=unlink", func(t *testing.T) {
+		var unlink = func(t *testing.T, agent, provider string) (body []byte, res *http.Response, req *models.SettingsRequest) {
+			req = nprSDK(t, agents[agent], "", time.Hour)
+			body, res = testhelpers.HTTPPostForm(t, agents[agent], action(req),
+				&url.Values{"csrf_token": {"nosurf"}, "unlink": {provider}})
+			return
+		}
+
+		var unlinkInvalid = func(agent, provider string, expectedFields models.FormFields) func(t *testing.T) {
+			return func(t *testing.T) {
+				body, res, req := unlink(t, agent, provider)
+				assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?request="+string(req.ID))
+
+				assert.EqualValues(t, identity.CredentialsTypeOIDC.String(), gjson.GetBytes(body, "active").String())
+				assert.Contains(t, gjson.GetBytes(body, "methods.oidc.config.action").String(), publicTS.URL+oidc.SettingsPath+"?request=")
+
+				// The original options to link google and github are still there
+				testhelpers.JSONEq(t, append(models.FormFields{csrfField}, expectedFields...),
+					json.RawMessage(gjson.GetBytes(body, `methods.oidc.config.fields`).Raw))
+
+				assert.Contains(t, gjson.GetBytes(body, `methods.oidc.config.errors.0.message`).String(),
+					"can not unlink non-existing OpenID Connect")
+			}
+		}
+
+		t.Run("case=should not be able to unlink the last remaining connection",
+			unlinkInvalid("oryer", "ory", expectedOryerFields))
+
+		t.Run("case=should not be able to unlink an non-existing connection",
+			unlinkInvalid("oryer", "i-do-not-exist", expectedOryerFields))
+
+		t.Run("case=should not be able to unlink a connection not yet linked",
+			unlinkInvalid("githuber", "google", expectedGithuberFields))
+
+		t.Run("case=should unlink a connection", func(t *testing.T) {
+			agent, provider := "githuber", "github"
+			t.Cleanup(reset(t))
+
+			body, res, req := unlink(t, agent, provider)
+			assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?request="+string(req.ID))
+			require.True(t, gjson.GetBytes(body, "update_successful").Bool())
+
+			checkCredentials(t, false, users[agent].ID, provider, "hackerman+github+"+testID)
+		})
+
+		t.Run("case=should not be able to unlink a connection without a privileged session", func(t *testing.T) {
+			agent, provider := "githuber", "github"
+
+			var runUnauthed = func(t *testing.T) *models.SettingsRequest {
+				viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, time.Millisecond)
+				time.Sleep(time.Millisecond)
+				t.Cleanup(reset(t))
+				_, res, req := unlink(t, agent, provider)
+				assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login")
+
+				rs, err := admin.Common.GetSelfServiceBrowserSettingsRequest(common.
+					NewGetSelfServiceBrowserSettingsRequestParams().WithHTTPClient(agents[agent]).
+					WithRequest(string(req.ID)))
+				require.NoError(t, err)
+				require.EqualValues(t, false, *rs.Payload.UpdateSuccessful)
+
+				checkCredentials(t, true, users[agent].ID, provider, "hackerman+github+"+testID)
+
+				return req
+			}
+
+			t.Run("subcase=should not update without re-auth", func(t *testing.T) {
+				_ = runUnauthed(t)
+			})
+
+			t.Run("subcase=should update after re-auth", func(t *testing.T) {
+				req := runUnauthed(t)
+
+				// fake login by allowing longer sessions...
+				viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, time.Minute*5)
+
+				body, res := testhelpers.HTTPPostForm(t, agents[agent], action(req),
+					&url.Values{"csrf_token": {"nosurf"}, "unlink": {provider}})
+				assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?request="+string(req.ID))
+
+				assert.True(t, gjson.GetBytes(body, "update_successful").Bool())
+
+				checkCredentials(t, false, users[agent].ID, provider, "hackerman+github+"+testID)
+			})
+		})
+	})
+
+	t.Run("suite=link", func(t *testing.T) {
+		var link = func(t *testing.T, agent, provider string) (body []byte, res *http.Response, req *models.SettingsRequest) {
+			req = nprSDK(t, agents[agent], "", time.Hour)
+			body, res = testhelpers.HTTPPostForm(t, agents[agent], action(req),
+				&url.Values{"csrf_token": {"nosurf"}, "link": {provider}})
+			return
+		}
+
+		var linkInvalid = func(agent, provider string, expectedFields models.FormFields) func(t *testing.T) {
+			return func(t *testing.T) {
+				body, res, req := link(t, agent, provider)
+				assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?request="+string(req.ID))
+
+				assert.EqualValues(t, identity.CredentialsTypeOIDC.String(), gjson.GetBytes(body, "active").String())
+				assert.Contains(t, gjson.GetBytes(body, "methods.oidc.config.action").String(), publicTS.URL+oidc.SettingsPath+"?request=")
+
+				// The original options to link google and github are still there
+				testhelpers.JSONEq(t, append(models.FormFields{csrfField}, expectedFields...),
+					json.RawMessage(gjson.GetBytes(body, `methods.oidc.config.fields`).Raw))
+
+				assert.Contains(t, gjson.GetBytes(body, `methods.oidc.config.errors.0.message`).String(),
+					"can not link unknown or already existing OpenID Connect connection")
+			}
+		}
+
+		t.Run("case=should not be able to link an non-existing connection",
+			linkInvalid("oryer", "i-do-not-exist", expectedOryerFields))
+
+		t.Run("case=should not be able to link a connection which already exists",
+			linkInvalid("githuber", "github", expectedGithuberFields))
+
+		t.Run("case=should not be able to link a connection already linked by another identity", func(t *testing.T) {
+			// While this theoretically allows for account enumeration - because we see an error indicator if an
+			// oidc connection is being linked that exists already - it would require the attacker to already
+			// have control over the social profile, in which case account enumeration is the least of our worries.
+			// Instead of using the oidc profile for enumeration, the attacker would use it for account takeover.
+
+			// This is the multiuser login id for google
+			subject = "hackerman+multiuser+" + testID
+			scope = []string{"openid"}
+
+			agent, provider := "githuber", "google"
+			body, res, _ := link(t, agent, provider)
+			assert.Contains(t, res.Request.URL.String(), errTS.URL)
+
+			t.Logf("%s", body)
+			assert.EqualValues(t, 409, gjson.GetBytes(body, `0.code`).Int())
+			assert.Contains(t, gjson.GetBytes(body, `0.message`).String(), "insert or update resource because a resource")
+		})
+
+		t.Run("case=should not be able to link a connection which is missing the ID token", func(t *testing.T) {
+			t.Cleanup(reset(t))
+
+			subject = "hackerman+scope-missing+" + testID
+			scope = []string{}
+
+			agent, provider := "githuber", "google"
+			body, res, _ := link(t, agent, provider)
+			assert.Contains(t, res.Request.URL.String(), uiTS.URL)
+
+			t.Logf("%s", body)
+			assert.Contains(t, gjson.GetBytes(body, `methods.oidc.config.errors.0.message`).String(),
+				"no id_token was returned")
+		})
+
+		t.Run("case=should not be able to link a connection which is missing the ID token", func(t *testing.T) {
+			t.Cleanup(reset(t))
+
+			subject = "hackerman+scope-missing+" + testID
+			scope = []string{}
+
+			agent, provider := "githuber", "google"
+			body, res, _ := link(t, agent, provider)
+			assert.Contains(t, res.Request.URL.String(), uiTS.URL)
+
+			assert.Contains(t, gjson.GetBytes(body, `methods.oidc.config.errors.0.message`).String(),
+				"no id_token was returned")
+		})
+
+		t.Run("case=should link a connection", func(t *testing.T) {
+			t.Cleanup(reset(t))
+
+			subject = "hackerman+new-connection+" + testID
+			scope = []string{"openid"}
+
+			agent, provider := "githuber", "google"
+			_, res, req := link(t, agent, provider)
+			assert.Contains(t, res.Request.URL.String(), uiTS.URL)
+
+			rs, err := admin.Common.GetSelfServiceBrowserSettingsRequest(common.
+				NewGetSelfServiceBrowserSettingsRequestParams().WithHTTPClient(agents[agent]).
+				WithRequest(string(req.ID)))
+			require.NoError(t, err)
+			require.EqualValues(t, true, *rs.Payload.UpdateSuccessful)
+
+			testhelpers.JSONEq(t, append(models.FormFields{csrfField}, models.FormFields{
+				{Type: pointerx.String("submit"), Name: pointerx.String("unlink"), Value: "ory"},
+				{Type: pointerx.String("submit"), Name: pointerx.String("unlink"), Value: "github"},
+				{Type: pointerx.String("submit"), Name: pointerx.String("unlink"), Value: "google"},
+			}...), rs.Payload.Methods[identity.CredentialsTypeOIDC.String()].Config.Fields)
+
+			checkCredentials(t, true, users[agent].ID, provider, subject)
+		})
+
+		t.Run("case=should not be able to link a connection without a privileged session", func(t *testing.T) {
+			agent, provider := "githuber", "google"
+			subject = "hackerman+new+google+" + testID
+
+			var runUnauthed = func(t *testing.T) *models.SettingsRequest {
+				viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, time.Millisecond)
+				time.Sleep(time.Millisecond)
+				t.Cleanup(reset(t))
+				_, res, req := link(t, agent, provider)
+				assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login")
+
+				rs, err := admin.Common.GetSelfServiceBrowserSettingsRequest(common.
+					NewGetSelfServiceBrowserSettingsRequestParams().WithHTTPClient(agents[agent]).
+					WithRequest(string(req.ID)))
+				require.NoError(t, err)
+				require.EqualValues(t, false, *rs.Payload.UpdateSuccessful)
+
+				checkCredentials(t, false, users[agent].ID, provider, subject)
+
+				return req
+			}
+
+			t.Run("subcase=should not update without re-auth", func(t *testing.T) {
+				_ = runUnauthed(t)
+			})
+
+			t.Run("subcase=should update after re-auth", func(t *testing.T) {
+				req := runUnauthed(t)
+
+				// fake login by allowing longer sessions...
+				viper.Set(configuration.ViperKeySelfServicePrivilegedAuthenticationAfter, time.Minute*5)
+
+				body, res := testhelpers.HTTPPostForm(t, agents[agent], action(req),
+					&url.Values{"csrf_token": {"nosurf"}, "unlink": {provider}})
+				assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?request="+string(req.ID))
+
+				assert.True(t, gjson.GetBytes(body, "update_successful").Bool())
+
+				checkCredentials(t, true, users[agent].ID, provider, subject)
+			})
+		})
+	})
+}
+
+func TestPopulateSettingsMethod(t *testing.T) {
+	nreg := func(t *testing.T, conf *oidc.ConfigurationCollection) *driver.RegistryDefault {
+		_, reg := internal.NewFastRegistryWithMocks(t)
+
+		viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://stub/registration.schema.json")
+		viper.Set(configuration.ViperKeyURLsSelfPublic, "https://www.ory.sh/")
+
+		viper.Set(configuration.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC), map[string]interface{}{
+			"enabled": true,
+			"config":  conf})
+		return reg
+	}
+
+	ns := func(t *testing.T, reg *driver.RegistryDefault) *oidc.Strategy {
+		ss, err := reg.SettingsStrategies().Strategy(identity.CredentialsTypeOIDC.String())
+		require.NoError(t, err)
+		return ss.(*oidc.Strategy)
+	}
+
+	nr := func() *settings.Request {
+		return &settings.Request{ID: x.NewUUID(), Methods: map[string]*settings.RequestMethod{}}
+	}
+
+	populate := func(t *testing.T, reg *driver.RegistryDefault, i *identity.Identity, req *settings.Request) *form.HTMLForm {
+		require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
+		require.NoError(t, ns(t, reg).PopulateSettingsMethod(new(http.Request), &session.Session{Identity: i, IdentityID: i.ID}, req))
+		require.NotNil(t, req.Methods[identity.CredentialsTypeOIDC.String()])
+		require.NotNil(t, req.Methods[identity.CredentialsTypeOIDC.String()].Config)
+		require.NotNil(t, req.Methods[identity.CredentialsTypeOIDC.String()].Config.RequestMethodConfigurator)
+		require.Equal(t, identity.CredentialsTypeOIDC.String(), req.Methods[identity.CredentialsTypeOIDC.String()].Method)
+		f := req.Methods[identity.CredentialsTypeOIDC.String()].Config.RequestMethodConfigurator.(*oidc.RequestMethod).HTMLForm
+		assert.Equal(t, "https://www.ory.sh"+oidc.SettingsPath+"?request="+req.ID.String(), f.Action)
+		assert.Equal(t, "POST", f.Method)
+		return f
+	}
+
+	defaultConfig := []oidc.Configuration{
+		{Provider: "generic", ID: "facebook"},
+		{Provider: "generic", ID: "google"},
+		{Provider: "generic", ID: "github"},
+	}
+
+	for k, tc := range []struct {
+		c      []oidc.Configuration
+		i      identity.Credentials
+		e      form.Fields
+		withpw bool
+	}{
+		{
+			c: []oidc.Configuration{},
+			e: form.Fields{
+				{Name: "csrf_token", Type: "hidden", Required: true, Value: "nosurf"},
+			},
+		},
+		{
+			c: []oidc.Configuration{
+				{Provider: "generic", ID: "github"},
+			},
+			e: form.Fields{
+				{Name: "csrf_token", Type: "hidden", Required: true, Value: "nosurf"},
+				{Name: "link", Type: "submit", Value: "github"},
+			},
+		},
+		{
+			c: defaultConfig,
+			e: form.Fields{
+				{Name: "csrf_token", Type: "hidden", Required: true, Value: "nosurf"},
+				{Name: "link", Type: "submit", Value: "facebook"},
+				{Name: "link", Type: "submit", Value: "google"},
+				{Name: "link", Type: "submit", Value: "github"},
+			},
+		},
+		{
+			c: defaultConfig,
+			e: form.Fields{
+				{Name: "csrf_token", Type: "hidden", Required: true, Value: "nosurf"},
+				{Name: "link", Type: "submit", Value: "facebook"},
+				{Name: "link", Type: "submit", Value: "google"},
+				{Name: "link", Type: "submit", Value: "github"},
+			},
+			i: identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{}, Config: []byte(`{}`)},
+		},
+		{
+			c: defaultConfig,
+			e: form.Fields{
+				{Name: "csrf_token", Type: "hidden", Required: true, Value: "nosurf"},
+				{Name: "link", Type: "submit", Value: "facebook"},
+				{Name: "link", Type: "submit", Value: "github"},
+			},
+			i: identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{
+				"google:1234",
+			}, Config: []byte(`{"providers":[{"provider":"google","subject":"1234"}]}`)},
+		},
+		{
+			c: defaultConfig,
+			e: form.Fields{
+				{Name: "csrf_token", Type: "hidden", Required: true, Value: "nosurf"},
+				{Name: "link", Type: "submit", Value: "facebook"},
+				{Name: "link", Type: "submit", Value: "github"},
+				{Name: "unlink", Type: "submit", Value: "google"},
+			},
+			withpw: true,
+			i: identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{
+				"google:1234",
+			},
+				Config: []byte(`{"providers":[{"provider":"google","subject":"1234"}]}`)},
+		},
+		{
+			c: defaultConfig,
+			e: form.Fields{
+				{Name: "csrf_token", Type: "hidden", Required: true, Value: "nosurf"},
+				{Name: "link", Type: "submit", Value: "github"},
+				{Name: "unlink", Type: "submit", Value: "google"},
+				{Name: "unlink", Type: "submit", Value: "facebook"},
+			},
+			i: identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{
+				"google:1234",
+				"facebook:1234",
+			},
+				Config: []byte(`{"providers":[{"provider":"google","subject":"1234"},{"provider":"facebook","subject":"1234"}]}`)},
+		},
+	} {
+		t.Run("iteration="+strconv.Itoa(k), func(t *testing.T) {
+			reg := nreg(t, &oidc.ConfigurationCollection{Providers: tc.c})
+			i := &identity.Identity{Traits: []byte(`{"subject":"foo@bar.com"}`),
+				Credentials: map[identity.CredentialsType]identity.Credentials{identity.CredentialsTypeOIDC: tc.i}}
+			if tc.withpw {
+				i.Credentials[identity.CredentialsTypePassword] = identity.Credentials{
+					Type:        identity.CredentialsTypePassword,
+					Identifiers: []string{"foo@bar.com"},
+					Config:      []byte(`{"hashed_password":"$argon2id$..."}`),
+				}
+			}
+			actual := populate(t, reg, i, nr())
+			assert.EqualValues(t, tc.e, actual.Fields)
+		})
+	}
+}
diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go
index 19a41452b..e2212ae02 100644
--- a/selfservice/strategy/oidc/strategy_test.go
+++ b/selfservice/strategy/oidc/strategy_test.go
@@ -2,19 +2,18 @@ package oidc_test
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/http/cookiejar"
 	"net/http/httptest"
 	"net/url"
-	"os"
 	"strings"
 	"testing"
 	"time"
 
 	"github.com/gofrs/uuid"
-	"github.com/pkg/errors"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/tidwall/gjson"
@@ -23,14 +22,10 @@ import (
 
 	"github.com/ory/viper"
 
-	"github.com/ory/dockertest"
-	"github.com/ory/dockertest/docker"
-
-	"github.com/phayes/freeport"
-
 	"github.com/ory/kratos/driver/configuration"
 	"github.com/ory/kratos/identity"
 	"github.com/ory/kratos/internal"
+	"github.com/ory/kratos/internal/testhelpers"
 	"github.com/ory/kratos/selfservice/flow/login"
 	"github.com/ory/kratos/selfservice/flow/registration"
 	"github.com/ory/kratos/selfservice/form"
@@ -41,120 +36,36 @@ import (
 const debugRedirects = false
 
 func TestStrategy(t *testing.T) {
-	var (
-		subject      string
-		scope        []string
-		remoteAdmin  = os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_ADMIN")
-		remotePublic = os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_PUBLIC")
-	)
-
-	hydraIntegrationTS, hydraIntegrationTSURL := newHydraIntegration(t, &remoteAdmin, &subject, &scope, os.Getenv("TEST_SELFSERVICE_OIDC_HYDRA_INTEGRATION_ADDR"))
-	defer hydraIntegrationTS.Close()
-
 	if testing.Short() {
 		t.Skip()
 	}
 
-	if remotePublic == "" && remoteAdmin == "" {
-		publicPort, err := freeport.GetFreePort()
-		require.NoError(t, err)
-
-		pool, err := dockertest.NewPool("")
-		require.NoError(t, err)
-		hydra, err := pool.RunWithOptions(&dockertest.RunOptions{
-			Repository: "oryd/hydra",
-			Tag:        "v1.2.2",
-			Env: []string{
-				"DSN=memory",
-				fmt.Sprintf("URLS_SELF_ISSUER=http://127.0.0.1:%d/", publicPort),
-				"URLS_LOGIN=" + hydraIntegrationTSURL + "/login",
-				"URLS_CONSENT=" + hydraIntegrationTSURL + "/consent",
-			},
-			Cmd:          []string{"serve", "all", "--dangerous-force-http"},
-			ExposedPorts: []string{"4444/tcp", "4445/tcp"},
-			PortBindings: map[docker.Port][]docker.PortBinding{
-				"4444/tcp": {{HostPort: fmt.Sprintf("%d/tcp", publicPort)}},
-			},
-		})
-		require.NoError(t, err)
-		defer hydra.Close()
-		require.NoError(t, hydra.Expire(uint(60*15)))
-
-		require.NotEmpty(t, hydra.GetPort("4444/tcp"), "%+v", hydra.Container.NetworkSettings.Ports)
-		require.NotEmpty(t, hydra.GetPort("4445/tcp"), "%+v", hydra.Container)
-
-		remotePublic = "http://127.0.0.1:" + hydra.GetPort("4444/tcp")
-		remoteAdmin = "http://127.0.0.1:" + hydra.GetPort("4445/tcp")
-	}
-
-	_, reg := internal.NewFastRegistryWithMocks(t)
-
-	public := x.NewRouterPublic()
-	admin := x.NewRouterAdmin()
-	reg.LoginHandler().RegisterPublicRoutes(public)
-	reg.LoginHandler().RegisterAdminRoutes(admin)
-	reg.LoginStrategies().RegisterPublicRoutes(public)
-	reg.RegistrationStrategies().RegisterPublicRoutes(public)
-	reg.RegistrationHandler().RegisterPublicRoutes(public)
-	reg.RegistrationHandler().RegisterAdminRoutes(admin)
-	ts := httptest.NewServer(public)
-	defer ts.Close()
+	var (
+		_, reg  = internal.NewFastRegistryWithMocks(t)
+		subject string
+		scope   []string
+	)
 
+	remoteAdmin, remotePublic, hydraIntegrationTSURL := newHydra(t, &subject, &scope)
 	returnTS := newReturnTs(t, reg)
-	defer returnTS.Close()
-
-	uiTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		var e interface{}
-		var err error
-		if r.URL.Path == "/login" {
-			e, err = reg.LoginRequestPersister().GetLoginRequest(r.Context(), x.ParseUUID(r.URL.Query().Get("request")))
-		} else if r.URL.Path == "/registration" {
-			e, err = reg.RegistrationRequestPersister().GetRegistrationRequest(r.Context(), x.ParseUUID(r.URL.Query().Get("request")))
-		}
-
-		require.NoError(t, err)
-		reg.Writer().Write(w, r, e)
-	}))
-	defer uiTS.Close()
-
-	createClient(t, remoteAdmin, ts.URL+oidc.BasePath+"/callback/valid")
-
-	cb := map[string]interface{}{
-		"config": &oidc.ConfigurationCollection{
-			Providers: []oidc.Configuration{
-				{
-					Provider:     "generic",
-					ID:           "valid",
-					ClientID:     "client",
-					ClientSecret: "secret",
-					IssuerURL:    remotePublic + "/",
-					Mapper:       "file://./stub/oidc.hydra.jsonnet",
-				},
-				{
-					Provider:     "generic",
-					ID:           "invalid-issuer",
-					ClientID:     "client",
-					ClientSecret: "secret",
-					IssuerURL:    strings.Replace(remotePublic, "127.0.0.1", "localhost", 1) + "/",
-					Mapper:       "file://./stub/oidc.hydra.jsonnet",
-				},
-			},
+	uiTS := newUI(t, reg)
+	errTS := testhelpers.NewErrorTestServer(t, reg)
+	ts, _ := testhelpers.NewKratosServer(t, reg)
+
+	viperSetProviderConfig(
+		newOIDCProvider(t, ts, remotePublic, remoteAdmin, "valid", "client"),
+		oidc.Configuration{
+			Provider:     "generic",
+			ID:           "invalid-issuer",
+			ClientID:     "client",
+			ClientSecret: "secret",
+			IssuerURL:    strings.Replace(remotePublic, "127.0.0.1", "localhost", 1) + "/",
+			Mapper:       "file://./stub/oidc.hydra.jsonnet",
 		},
-	}
-
-	errTS := newErrTs(t, reg)
-	defer errTS.Close()
-
-	viper.Set(configuration.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC), cb)
-	viper.Set(configuration.ViperKeyURLsSelfPublic, ts.URL)
-	viper.Set(configuration.ViperKeyURLsError, errTS.URL)
-	viper.Set(configuration.ViperKeyURLsLogin, uiTS.URL+"/login")
-	viper.Set(configuration.ViperKeyURLsRegistration, uiTS.URL+"/registration")
+	)
 	viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/registration.schema.json")
-	viper.Set(configuration.ViperKeyURLsDefaultReturnTo, returnTS.URL)
-	viper.Set(
-		configuration.HookStrategyKey(configuration.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()),
-		[]configuration.SelfServiceHook{{Name: "session"}})
+	viper.Set(configuration.HookStrategyKey(configuration.ViperKeySelfServiceRegistrationAfter,
+		identity.CredentialsTypeOIDC.String()), []configuration.SelfServiceHook{{Name: "session"}})
 
 	t.Logf("Kratos Public URL: %s", ts.URL)
 	t.Logf("Kratos Error URL: %s", errTS.URL)
@@ -163,29 +74,6 @@ func TestStrategy(t *testing.T) {
 	t.Logf("Hydra Integration URL: %s", hydraIntegrationTSURL)
 	t.Logf("Return URL: %s", returnTS.URL)
 
-	var newClient = func(t *testing.T, jar *cookiejar.Jar) *http.Client {
-		if jar == nil {
-			j, err := cookiejar.New(nil)
-			jar = j
-			require.NoError(t, err)
-		}
-		return &http.Client{
-			Jar: jar,
-			CheckRedirect: func(req *http.Request, via []*http.Request) error {
-				if debugRedirects {
-					t.Logf("Redirect: %s", req.URL.String())
-				}
-				if len(via) >= 20 {
-					for k, v := range via {
-						t.Logf("Failed with redirect (%d): %s", k, v.URL.String())
-					}
-					return errors.New("stopped after 20 redirects")
-				}
-				return nil
-			},
-		}
-	}
-
 	subject = "foo@bar.com"
 	scope = []string{}
 
@@ -597,3 +485,99 @@ func TestStrategy(t *testing.T) {
 		assert.EqualValues(t, expected.Config.RequestMethodConfigurator.(*oidc.RequestMethod).HTMLForm, actual.Config.RequestMethodConfigurator.(*oidc.RequestMethod).HTMLForm)
 	})
 }
+
+func TestCountActiveCredentials(t *testing.T) {
+	conf, reg := internal.NewFastRegistryWithMocks(t)
+	strategy := oidc.NewStrategy(reg, conf)
+
+	toJson := func(c oidc.CredentialsConfig) []byte {
+		out, err := json.Marshal(&c)
+		require.NoError(t, err)
+		return out
+	}
+
+	for k, tc := range []struct {
+		in       identity.CredentialsCollection
+		expected int
+	}{
+		{
+			in: identity.CredentialsCollection{{
+				Type:   strategy.ID(),
+				Config: json.RawMessage{},
+			}},
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type: strategy.ID(),
+				Config: toJson(oidc.CredentialsConfig{Providers: []oidc.ProviderCredentialsConfig{
+					{Subject: "foo", Provider: "bar"},
+				}}),
+			}},
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{""},
+				Config: toJson(oidc.CredentialsConfig{Providers: []oidc.ProviderCredentialsConfig{
+					{Subject: "foo", Provider: "bar"},
+				}}),
+			}},
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{"bar:"},
+				Config: toJson(oidc.CredentialsConfig{Providers: []oidc.ProviderCredentialsConfig{
+					{Subject: "foo", Provider: "bar"},
+				}}),
+			}},
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{":foo"},
+				Config: toJson(oidc.CredentialsConfig{Providers: []oidc.ProviderCredentialsConfig{
+					{Subject: "foo", Provider: "bar"},
+				}}),
+			}},
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{"not-bar:foo"},
+				Config: toJson(oidc.CredentialsConfig{Providers: []oidc.ProviderCredentialsConfig{
+					{Subject: "foo", Provider: "bar"},
+				}}),
+			}},
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{"bar:not-foo"},
+				Config: toJson(oidc.CredentialsConfig{Providers: []oidc.ProviderCredentialsConfig{
+					{Subject: "foo", Provider: "bar"},
+				}}),
+			}},
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{"bar:foo"},
+				Config: toJson(oidc.CredentialsConfig{Providers: []oidc.ProviderCredentialsConfig{
+					{Subject: "foo", Provider: "bar"},
+				}}),
+			}},
+			expected: 1,
+		},
+	} {
+		t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
+			in := make(map[identity.CredentialsType]identity.Credentials)
+			for _, v := range tc.in {
+				in[v.Type] = v
+			}
+			actual, err := strategy.CountActiveCredentials(in)
+			require.NoError(t, err)
+			assert.Equal(t, tc.expected, actual)
+		})
+	}
+}
diff --git a/selfservice/strategy/password/hasher_test.go b/selfservice/strategy/password/hasher_test.go
index 9ff745293..1613e5876 100644
--- a/selfservice/strategy/password/hasher_test.go
+++ b/selfservice/strategy/password/hasher_test.go
@@ -37,6 +37,7 @@ func TestHasher(t *testing.T) {
 					require.NoError(t, err)
 					assert.NotEqual(t, pw, hs)
 
+					t.Logf("hash: %s", hs)
 					require.NoError(t, h.Compare(pw, hs))
 
 					mod := make([]byte, len(pw))
diff --git a/selfservice/strategy/password/settings_test.go b/selfservice/strategy/password/settings_test.go
index 9ab0595e0..013460e76 100644
--- a/selfservice/strategy/password/settings_test.go
+++ b/selfservice/strategy/password/settings_test.go
@@ -27,7 +27,7 @@ func init() {
 	internal.RegisterFakes()
 }
 
-func TestProfile(t *testing.T) {
+func TestSettings(t *testing.T) {
 	_, reg := internal.NewFastRegistryWithMocks(t)
 	viper.Set(configuration.ViperKeyURLsDefaultReturnTo, "https://www.ory.sh/")
 	viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/profile.schema.json")
@@ -50,9 +50,11 @@ func TestProfile(t *testing.T) {
 		Traits:         identity.Traits(`{}`),
 		TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID,
 	}
-	publicTS, adminTS := testhelpers.NewSettingsAPIServer(t, reg, []identity.Identity{*primaryIdentity, *secondaryIdentity})
-	primaryUser := testhelpers.NewSessionClient(t, publicTS.URL+"/sessions/set/0")
-	secondaryUser := testhelpers.NewSessionClient(t, publicTS.URL+"/sessions/set/1")
+	publicTS, adminTS, clients := testhelpers.NewSettingsAPIServer(t, reg, map[string]*identity.Identity{
+		"primary": primaryIdentity, "secondary": secondaryIdentity})
+
+	primaryUser := clients["primary"]
+	secondaryUser := clients["secondary"]
 	adminClient := testhelpers.NewSDKClient(adminTS)
 
 	t.Run("description=should fail if password violates policy", func(t *testing.T) {
diff --git a/selfservice/strategy/password/strategy_test.go b/selfservice/strategy/password/strategy_test.go
index 3c93fe083..9a26b1d8a 100644
--- a/selfservice/strategy/password/strategy_test.go
+++ b/selfservice/strategy/password/strategy_test.go
@@ -1,16 +1,19 @@
 package password_test
 
 import (
-	"bytes"
 	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 
+	"github.com/bmizerany/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/internal"
 	"github.com/ory/kratos/selfservice/errorx"
+	"github.com/ory/kratos/selfservice/strategy/password"
 	"github.com/ory/kratos/session"
 	"github.com/ory/kratos/x"
 )
@@ -37,26 +40,75 @@ func newReturnTs(t *testing.T, reg interface {
 	}))
 }
 
-func hookConfig(u string) (m []map[string]interface{}) {
-	var b bytes.Buffer
-	if _, err := fmt.Fprintf(&b, `[
-	{
-		"hook": "session"
-	},
-	{
-		"hook": "redirect",
-		"config": {
-          "default_redirect_url": "%s",
-          "allow_user_defined_redirect": true
-		}
-	}
-]`, u); err != nil {
-		panic(err)
-	}
+func TestCountActiveCredentials(t *testing.T) {
+	conf, reg := internal.NewFastRegistryWithMocks(t)
+	strategy := password.NewStrategy(reg, conf)
 
-	if err := json.NewDecoder(&b).Decode(&m); err != nil {
-		panic(err)
-	}
+	hash, err := reg.PasswordHasher().Generate([]byte("a password"))
+	require.NoError(t, err)
 
-	return m
+	for k, tc := range []struct {
+		in       identity.CredentialsCollection
+		expected int
+	}{
+		{
+			in: identity.CredentialsCollection{{
+				Type:   strategy.ID(),
+				Config: json.RawMessage{},
+			}},
+			expected: 0,
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:   strategy.ID(),
+				Config: json.RawMessage(`{"hashed_password": "` + string(hash) + `"}`),
+			}},
+			expected: 0,
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{""},
+				Config:      json.RawMessage(`{"hashed_password": "` + string(hash) + `"}`),
+			}},
+			expected: 0,
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:        strategy.ID(),
+				Identifiers: []string{"foo"},
+				Config:      json.RawMessage(`{"hashed_password": "` + string(hash) + `"}`),
+			}},
+			expected: 1,
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:   strategy.ID(),
+				Config: json.RawMessage(`{"hashed_password": "asdf"}`),
+			}},
+			expected: 0,
+		},
+		{
+			in: identity.CredentialsCollection{{
+				Type:   strategy.ID(),
+				Config: json.RawMessage(`{}`),
+			}},
+			expected: 0,
+		},
+		{
+			in:       identity.CredentialsCollection{{}, {}},
+			expected: 0,
+		},
+	} {
+		t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
+			cc := map[identity.CredentialsType]identity.Credentials{}
+			for _, c := range tc.in {
+				cc[c.Type] = c
+			}
+
+			actual, err := strategy.CountActiveCredentials(cc)
+			require.NoError(t, err)
+			assert.Equal(t, tc.expected, actual)
+		})
+	}
 }
diff --git a/test/e2e/cypress/integration/profiles/oidc/settings/error.spec.js b/test/e2e/cypress/integration/profiles/oidc/settings/error.spec.js
new file mode 100644
index 000000000..94d0e6207
--- /dev/null
+++ b/test/e2e/cypress/integration/profiles/oidc/settings/error.spec.js
@@ -0,0 +1,28 @@
+import { APP_URL, gen, website } from '../../../../helpers'
+
+context('Settings', () => {
+  const up = (value) => `not-${value}`
+  const down = (value) => value.replace(/not-/, '')
+  let email
+
+  beforeEach(() => {
+    cy.clearCookies()
+    email = gen.email()
+
+    cy.registerOidc({ email, expectSession: true, website })
+    cy.visit(APP_URL + '/settings')
+  })
+
+  describe('oidc', () => {
+    it('should fail to link google because id token is missing', () => {
+      cy.get('#user-oidc button[value="google"]').click()
+      cy.get('#remember').click()
+      cy.get('#accept').click()
+
+      cy.get('#user-oidc').should(
+        'contain.text',
+        'Authentication failed because no id_token was returned. Please accept the "openid" permission and try again.'
+      )
+    })
+  })
+})
diff --git a/test/e2e/cypress/integration/profiles/oidc/settings/success.spec.js b/test/e2e/cypress/integration/profiles/oidc/settings/success.spec.js
new file mode 100644
index 000000000..d353208be
--- /dev/null
+++ b/test/e2e/cypress/integration/profiles/oidc/settings/success.spec.js
@@ -0,0 +1,146 @@
+import { APP_URL, gen, website } from '../../../../helpers'
+
+context('Settings', () => {
+  let email
+
+  const hydraReauthFails = () => {
+    cy.clearCookies()
+    cy.visit(APP_URL + '/auth/login')
+    cy.get('button[value="hydra"]').click()
+
+    cy.get('#username').type(email)
+    cy.get('#remember').click()
+    cy.get('#accept').click()
+
+    cy.get('input[name="traits.website"]').clear().type(website)
+    cy.get('button[value="hydra"]').click()
+
+    cy.get('.form-errors .message').should(
+      'contain.text',
+      'an account with the same identifier'
+    )
+
+    cy.noSession()
+  }
+
+  beforeEach(() => {
+    cy.clearCookies()
+    email = gen.email()
+
+    cy.registerOidc({ email, expectSession: true, website })
+    cy.visit(APP_URL + '/settings')
+  })
+
+  describe('oidc', () => {
+    it('should show the correct options', () => {
+      cy.get('#user-oidc button[value="hydra"]').should('not.exist')
+
+      cy.get('#user-oidc button[value="google"]')
+        .should('have.attr', 'name', 'link')
+        .should('contain.text', 'Link google')
+
+      cy.get('#user-oidc button[value="github"]')
+        .should('have.attr', 'name', 'link')
+        .should('contain.text', 'Link github')
+    })
+
+    it('should show the unlink once password is set', () => {
+      cy.get('#user-oidc button[value="hydra"]').should('not.exist')
+
+      cy.get('#user-password input[name="password"]').type(gen.password())
+      cy.get('#user-password button[type="submit"]').click()
+      cy.visit(APP_URL + '/settings')
+
+      cy.get('#user-oidc button[value="hydra"]')
+        .should('have.attr', 'name', 'unlink')
+        .should('contain.text', 'Unlink hydra')
+    })
+
+    it('should link google', () => {
+      cy.get('#user-oidc button[value="google"]').click()
+
+      cy.get('input[name="scope"]').each(($el) => cy.wrap($el).click())
+      cy.get('#remember').click()
+      cy.get('#accept').click()
+
+      cy.visit(APP_URL + '/settings')
+
+      cy.get('#user-oidc button[value="google"]')
+        .should('have.attr', 'name', 'unlink')
+        .should('contain.text', 'Unlink google')
+
+      cy.logout()
+
+      cy.visit(APP_URL + '/auth/login')
+      cy.get('button[value="google"]').click()
+      cy.session()
+    })
+
+    it('should link google after re-auth', () => {
+      cy.waitForPrivilegedSessionToExpire()
+      cy.get('#user-oidc button[value="google"]').click()
+      cy.location('pathname').should('equal', '/auth/login')
+      cy.get('button[value="hydra"]').click()
+
+      // prompt=login means that we need to re-auth!
+      cy.get('#username').type(email)
+      cy.get('#accept').click()
+
+      // we re-authed, now we do the google oauth2 dance
+      cy.get('#username').type(gen.email())
+      cy.get('#accept').click()
+      cy.get('input[name="scope"]').each(($el) => cy.wrap($el).click())
+      cy.get('#accept').click()
+
+      cy.get('.container').should(
+        'contain.text',
+        'Your changes have been saved!'
+      )
+
+      cy.get('#user-oidc button[value="google"]')
+        .should('have.attr', 'name', 'unlink')
+        .should('contain.text', 'Unlink google')
+
+      cy.visit(APP_URL + '/settings')
+
+      cy.get('#user-oidc button[value="google"]')
+        .should('have.attr', 'name', 'unlink')
+        .should('contain.text', 'Unlink google')
+    })
+
+    it('should unlink hydra and no longer be able to sign in', () => {
+      cy.get('#user-oidc button[value="hydra"]').should('not.exist')
+
+      cy.get('#user-password input[name="password"]').type(gen.password())
+      cy.get('#user-password button[type="submit"]').click()
+      cy.visit(APP_URL + '/settings')
+
+      cy.get('#user-oidc button[value="hydra"]').click()
+
+      // It will no longer be possible to sign up with this provider because of a UNIQUE key violation
+      // because of the email verification table.
+      //
+      // Basically what needs to happen is for the user to use the LINK feature to link this account.
+      hydraReauthFails()
+    })
+
+    it('should unlink hydra after reauth', () => {
+      cy.get('#user-oidc button[value="hydra"]').should('not.exist')
+
+      cy.get('#user-password input[name="password"]').type(gen.password())
+      cy.get('#user-password button[type="submit"]').click()
+      cy.visit(APP_URL + '/settings')
+
+      cy.waitForPrivilegedSessionToExpire()
+      cy.get('#user-oidc button[value="hydra"]').click()
+      cy.location('pathname').should('equal', '/auth/login')
+      cy.get('button[value="hydra"]').click()
+
+      // prompt=login means that we need to re-auth!
+      cy.get('#username').type(email)
+      cy.get('#accept').click()
+
+      hydraReauthFails()
+    })
+  })
+})
diff --git a/cypress/support/index.js b/test/e2e/cypress/support/index.js
similarity index 100%
rename from cypress/support/index.js
rename to test/e2e/cypress/support/index.js
diff --git a/cypress/tsconfig.json b/test/e2e/cypress/tsconfig.json
similarity index 100%
rename from cypress/tsconfig.json
rename to test/e2e/cypress/tsconfig.json
diff --git a/test/e2e/profiles/kratos.base.yml b/test/e2e/profiles/kratos.base.yml
index 32b1c0661..edad133b6 100644
--- a/test/e2e/profiles/kratos.base.yml
+++ b/test/e2e/profiles/kratos.base.yml
@@ -1,5 +1,5 @@
 log:
-  level: debug
+  level: trace
 
 secrets:
   session:
diff --git a/test/e2e/profiles/oidc/.kratos.yml b/test/e2e/profiles/oidc/.kratos.yml
index b02a84cd3..2cd673cfd 100644
--- a/test/e2e/profiles/oidc/.kratos.yml
+++ b/test/e2e/profiles/oidc/.kratos.yml
@@ -12,6 +12,22 @@ selfservice:
             scope:
               - offline
             mapper_url: file://test/e2e/profiles/oidc/hydra.jsonnet
+          - id: google
+            provider: generic
+            client_id: google-client
+            client_secret: kratos-secret
+            issuer_url: http://127.0.0.1:4444/
+            scope:
+              - offline
+            mapper_url: file://test/e2e/profiles/oidc/hydra.jsonnet
+          - id: github
+            provider: generic
+            client_id: github-client
+            client_secret: kratos-secret
+            issuer_url: http://127.0.0.1:4444/
+            scope:
+              - offline
+            mapper_url: file://test/e2e/profiles/oidc/hydra.jsonnet
 
     password:
       enabled: true
diff --git a/test/e2e/run.sh b/test/e2e/run.sh
index 757635861..2e5f42e61 100755
--- a/test/e2e/run.sh
+++ b/test/e2e/run.sh
@@ -60,7 +60,25 @@ run() {
     --grant-types authorization_code,refresh_token \
     --response-types code,id_token \
     --scope openid,offline \
-    --callbacks http://127.0.0.1:4455/.ory/kratos/public/self-service/browser/flows/registration/strategies/oidc/callback/hydra
+    --callbacks http://127.0.0.1:4455/.ory/kratos/public/self-service/browser/flows/strategies/oidc/callback/hydra
+
+  hydra clients create \
+    --endpoint http://127.0.0.1:4445 \
+    --id google-client \
+    --secret kratos-secret \
+    --grant-types authorization_code,refresh_token \
+    --response-types code,id_token \
+    --scope openid,offline \
+    --callbacks http://127.0.0.1:4455/.ory/kratos/public/self-service/browser/flows/strategies/oidc/callback/google
+
+  hydra clients create \
+    --endpoint http://127.0.0.1:4445 \
+    --id github-client \
+    --secret kratos-secret \
+    --grant-types authorization_code,refresh_token \
+    --response-types code,id_token \
+    --scope openid,offline \
+    --callbacks http://127.0.0.1:4455/.ory/kratos/public/self-service/browser/flows/strategies/oidc/callback/github
 
   if [ -z ${KRATOS_APP_PATH+x} ]; then
     (cd "$dir"; PORT=4455 SECURITY_MODE=cookie npm run serve \
@@ -87,9 +105,9 @@ run() {
     http-get://127.0.0.1:4437/mail
 
   if [[ $dev = "yes" ]]; then
-    npm run test:watch -- --config integrationFolder="cypress/integration/profiles/$profile"
+    npm run test:watch -- --config integrationFolder="test/e2e/cypress/integration/profiles/$profile"
   else
-    npm run test -- --config integrationFolder="cypress/integration/profiles/$profile"
+    npm run test -- --config integrationFolder="test/e2e/cypress/integration/profiles/$profile"
   fi
 }