Repository: ory/kratos
Pull request #1355 (2021-05-18 09:03:52)

feat: require verified address
## Related issue

@aeneasr: This is a promised PR for #1328.

## Proposed changes

As discussed in #1328. The only difference is the configuration. Instead of making the hook available in `before` property, the `after` is used as the information about the identity is only available there.

```.yaml
flows:
    login:
      ui_url: http://127.0.0.1:9090/identity/login
      lifespan: 10m
      after:
        hooks:
          - hook: require_verified_address
```

## Checklist

- [x] I have read the [contributing guidelines](../blob/master/CONTRIBUTING.md).
- [x] I have read the [security policy](../security/policy).
- [x] I confirm that this pull request does not address a security
      vulnerability. If this pull request addresses a security. vulnerability, I
      confirm that I got green light (please contact
      [security@ory.sh](mailto:security@ory.sh)) from the maintainers to push
      the changes.
- [x] I have added tests that prove my fix is effective or that my feature
      works.
- [x] I have added or changed [the documentation](docs/docs).

dadrus: @aeneasr: In addition to the documentation update (which will follow), I would like to clarify one thing: 

An identity can have multiple verifiable addresses. The current implementation of the PR expects all of these to be verified. This might be an issue. One could change the behavior to make it expect at least one verified address, but this might be problematic as well. In our case, we would like to have the eMail address used for the current login attempt verified. So maybe kind of a configuration with useful defaults is required. So something like this:
```.yaml
  hook: require_verified_address
  config:
    verify: all # or alternatively 'one', or 'current' 
```
If the config is omitted, `one` might be a useful default.

If we go this way, how can I get the information about the email address used for the login? But there are also other related topics. In our case, we also support logins with user names. In this case, the expectation is, that at least `one` email address is verified. This however would not be possible to configure given the above configuration approach.

Any ideas from your side?
Zageron: What happens if someone's session expires immediately after changing their primary email, and they misspell it? Blocked from logging in because email is unverified and unable to verify because wrong email? Does the email change flow have a verification component in order to successfully apply?
radekg: Interesting question the one above. I’d expect the app to not allow changing an email address if there is no another confirmed address on the account.
aeneasr: Hello there, this is a great contribution! Sorry for the late review but heading off to holidays and had a ton of stuff on my plate :)

So generally I would suggest to avoid the one / all / current mechanism. If we add more authentication methods, this will get blurier. What for example about OpenID Connect logins that do not have an email address associated?

I think the "one" strategy is good enough. If the user has verified an address I think it is safe to assume that the user has passed that challenge. I'm not sure if having multiple addresses be verified would add an additional benefit - on the contrary! Users might be wondering "why do I need to verify several email addresses?" or "I verified my email address why can I not log in?".

From my side - the one strategy as the default would be absolutely good enough! :)
radekg: On the other hand, if I signed up using an email and a password, I should be able to recover using one of the social logins. I actually did that once with meetup.com. I created an account using facebook sso but I deleted my facebook account and was unable to sign in. I was able to recover the account by signing up with google directly. This linked the account to the old one and all was good.

The second thought I have, isn't signing up with social login an implied verification? If I signed up with Google SSO, Google just told you that the identity is genuine. If someone else was able to sign up using my Google account, well, that was a game over because my Google account was already compromised. So a registration via SSO linking implies a verified address, IMO.

Enjoy your holidays.
dadrus: @aeneasr: Yeah, simpler is better :) I'll update the code accordingly next week and will also add documentation.

Enjoy the long weekend!
dadrus: @radekg: Maybe I miss something, but how do you see this PR is related to recovery?

> The second thought I have, isn't signing up with social login an implied verification?
radekg: > @radekg: Maybe I miss something, but how do you see this PR is related to recovery?

Sorry, I think I’ve mixed stuff up with https://github.com/ory/kratos/pull/1323#issuecomment-846232227
dadrus: @aeneasr: Please take a look. There are some questions (as comments), which I would like to clarify, before finalizing the implementation and based on that updating the documentation.
dadrus: > It probably also makes sense to only allow this hook for password flows? For OIDC flows I don't think it makes sense?

Yes, I was thinking to adjust the schema for this purpose. Will come with the next commits
dadrus: @aeneasr: Please take a look. If I haven't forgotten anything, this PR should be complete.
dadrus: @aeneasr: I need your help. This is first time I have to deal with JS and cypress. I tried to implement the test cases, but unfortunately there were issues running the registration scenario locally. Only after completely rewriting the `register` command in `commands.js` the registration was successful. This way I was able to finalize the negative test. But now I run into issues while trying to verify the email in a successful case. The mailslurper just doesn't receive any emails and I don't understand why. Could you please support here? Thank you in advance. 
dadrus: The tests are not final, as there were two issues, I've written in https://github.com/ory/kratos/pull/1355#issuecomment-858553414. I appreciate, if you could help with those
Quasarman: Hello,

First of all thanks for the efforts for implementing this behavior!
Are there any further updates when this will become available?

BR
aeneasr: While the PR is being worked on I will mark it as a draft. That declutters our review backlog :)

Once you're done with your changes and would like someone to review them, mark the PR as ready and request a review from one of the maintainers.

Thank you!
dadrus: @Quasarman: I was on vacation until yesterday and before it, there were some urgent topics in a project I'm working for, which I had to address.
The actual implementation is actually complete. The things which are missing are the e2e tests, which I will address asap
Quasarman: Hello @dadrus ,

no worries thanks a lot for your efforts!
aeneasr: Nice, I hope you enjoyed your days off @dadrus :)
dadrus: Definitely :) Recharging batteries feels always good ;)
dadrus: @aeneasr: There are no much changes in the last commit - only some simplifications compared to what you've done during your call.
aeneasr:  @dadrus yes you are right, looks like we don't need the reset method at all :) 
codecov[bot]: # [Codecov](https://codecov.io/gh/ory/kratos/pull/1355?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) Report
> Merging [#1355](https://codecov.io/gh/ory/kratos/pull/1355?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) (3c9e315) into [master](https://codecov.io/gh/ory/kratos/commit/1aaf6c07dcb471b4d80529cc47a3306cbaee3ce8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) (1aaf6c0) will **increase** coverage by `0.05%`.
> The diff coverage is `83.33%`.

[![Impacted file tree graph](https://codecov.io/gh/ory/kratos/pull/1355/graphs/tree.svg?width=650&height=150&src=pr&token=6t0QqOdurR&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory)](https://codecov.io/gh/ory/kratos/pull/1355?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory)

```diff
@@            Coverage Diff             @@
##           master    #1355      +/-   ##
==========================================
+ Coverage   74.19%   74.24%   +0.05%     
==========================================
  Files         258      259       +1     
  Lines       12577    12618      +41     
==========================================
+ Hits         9331     9368      +37     
- Misses       2627     2628       +1     
- Partials      619      622       +3     
```


| [Impacted Files](https://codecov.io/gh/ory/kratos/pull/1355?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) | Coverage Δ | |
|---|---|---|
| [driver/registry\_default.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZHJpdmVyL3JlZ2lzdHJ5X2RlZmF1bHQuZ28=) | `87.05% <ø> (ø)` | |
| [selfservice/flow/login/error.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-c2VsZnNlcnZpY2UvZmxvdy9sb2dpbi9lcnJvci5nbw==) | `77.41% <ø> (ø)` | |
| [selfservice/strategy/oidc/strategy\_login.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-c2VsZnNlcnZpY2Uvc3RyYXRlZ3kvb2lkYy9zdHJhdGVneV9sb2dpbi5nbw==) | `63.33% <25.00%> (-1.58%)` | :arrow_down: |
| [selfservice/strategy/password/login.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-c2VsZnNlcnZpY2Uvc3RyYXRlZ3kvcGFzc3dvcmQvbG9naW4uZ28=) | `82.97% <33.33%> (-3.39%)` | :arrow_down: |
| [selfservice/flow/login/handler.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-c2VsZnNlcnZpY2UvZmxvdy9sb2dpbi9oYW5kbGVyLmdv) | `68.99% <60.00%> (+1.02%)` | :arrow_up: |
| [selfservice/hook/address\_verifier.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-c2VsZnNlcnZpY2UvaG9vay9hZGRyZXNzX3ZlcmlmaWVyLmdv) | `86.66% <86.66%> (ø)` | |
| [driver/registry\_default\_hooks.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZHJpdmVyL3JlZ2lzdHJ5X2RlZmF1bHRfaG9va3MuZ28=) | `90.69% <100.00%> (+1.50%)` | :arrow_up: |
| [schema/errors.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-c2NoZW1hL2Vycm9ycy5nbw==) | `56.69% <100.00%> (+2.91%)` | :arrow_up: |
| [selfservice/flow/login/hook.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-c2VsZnNlcnZpY2UvZmxvdy9sb2dpbi9ob29rLmdv) | `90.90% <100.00%> (+1.51%)` | :arrow_up: |
| [text/message\_validation.go](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-dGV4dC9tZXNzYWdlX3ZhbGlkYXRpb24uZ28=) | `64.70% <100.00%> (+2.80%)` | :arrow_up: |
| ... and [2 more](https://codecov.io/gh/ory/kratos/pull/1355/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/ory/kratos/pull/1355?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/ory/kratos/pull/1355?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory). Last update [3eb87bc...3c9e315](https://codecov.io/gh/ory/kratos/pull/1355?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory).

aeneasr: Sorry for the breaking test - I had a fix locally but looks like you were faster 😅 
dadrus: @aeneasr: If you're not going to break any further tests in a review :), I would say I've done everything required for this feature. Last commits have a self explanatory commit comments :), so feel free to merge or ask for further updates ;)  
aeneasr: yeah sorry about that again 😓 also LGTM from my side

Patch:
diff --git a/docs/docs/concepts/identity-data-model.mdx b/docs/docs/concepts/identity-data-model.mdx
index a5b1f3c57..1803d84bf 100644
--- a/docs/docs/concepts/identity-data-model.mdx
+++ b/docs/docs/concepts/identity-data-model.mdx
@@ -172,6 +172,16 @@ switch (session.Identity.SchemaID) {
 }
 ```
 
+:::warning
+
+Please keep in mind that the Identity Model is not a place to store sensitive
+information that should not be visible to the end-user! Users can see traits and
+other data - except credentials - using the `sessions/whoami` endpoint. The
+Identity Model is not the right place to store data that should be obfuscated
+from the user!
+
+:::
+
 ## JSON Schema Vocabulary Extensions
 
 Because Ory Kratos does not know that a particular field has a system-relevant
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 c01b6df2d..7b783d6ba 100644
--- a/docs/docs/self-service/flows/verify-email-account-activation.mdx
+++ b/docs/docs/self-service/flows/verify-email-account-activation.mdx
@@ -105,14 +105,19 @@ selfservice:
 
 ## Account Activation
 
-Using this feature implements the so-called "account activation" with the
-difference that Ory Kratos does not provide a feature that prevents signing into
-accounts without verified addresses. The reason being that verification is
-proving that the user controls the given address, but it is not an
-authentication mechanism.
-
-You may however choose to limit what an identity without verified addresses is
-able to do in your application logic or API Gateways.
+Using this feature implements the so-called "account activation". Depending on
+your scenario or use cases, you may either choose to limit what an identity
+without verified addresses is able to do in your application logic or API
+Gateways, or make Ory Kratos prevent signing into accounts without verified
+addresses. Latter is possible by the usage of `require_verified_address` hook
+(See [Hooks](../hooks.mdx) for more details).
+
+Please be aware, that since `require_verified_address` hook is enforcing a
+verified address before the user can login, a typo in an email address done by
+the user either during the registration or as part of a self service flow (email
+change) will make the login for that user impossible. So you should think about
+measures to prevent such situations, like requiring two email addresses being
+configured by the user, thus having a backup if something goes wrong.
 
 ## Verification Methods
 
diff --git a/docs/docs/self-service/hooks.mdx b/docs/docs/self-service/hooks.mdx
index ad078c024..c86da3252 100644
--- a/docs/docs/self-service/hooks.mdx
+++ b/docs/docs/self-service/hooks.mdx
@@ -6,7 +6,8 @@ title: Hooks
 Hooks execute logic after a flow (login, registration, settings, ...):
 
 - _Before login:_ is executed when a login flow starts.
-- _After login:_ is executed after a login was successful.
+- _After login:_ is executed after a successful identification and
+  authentication of the user, but before the kratos session is created.
 - _Before registration:_ is executed when a registration flow starts.
 - _After registration:_ is executed when a registration was successful:
   - _Before persisting:_ runs before the identity is saved in the database.
@@ -191,12 +192,13 @@ selfservice:
         password:
           hooks:
             - hook: revoke_active_sessions
+            - hook: require_verified_address
 ```
 
 #### `revoke_active_sessions`
 
 The `revoke_active_sessions` will delete all active sessions for that user on
-successful login:
+successful login. This hook is available for all supported methods:
 
 ```yaml title="path/to/my/kratos.config.yml"
 selfservice:
@@ -209,6 +211,29 @@ selfservice:
               # can not be configured
 ```
 
+#### `require_verified_address`
+
+The `require_verified_address` will ensure, the user can login only if its email
+address is verified. This hook is available for the `password` method only:
+
+```yaml title="path/to/my/kratos.config.yml"
+selfservice:
+  flows:
+    login:
+      after:
+        password:
+          hooks:
+            - hook: require_verified_address
+              # can not be configured
+```
+
+Please be aware, that since `require_verified_address` hook is enforcing a
+verified address before the user can login, a typo in an email address done by
+the user either during the registration or as part of a self service flow (email
+change) will make the login for that user impossible. So you should think about
+measures to prevent such situations, like requiring two email addresses being
+configured by the user, thus having a backup if something goes wrong.
+
 ## Registration
 
 Hooks running before or after successful user registration are defined per
diff --git a/docs/versioned_docs/version-v0.7/concepts/identity-data-model.mdx b/docs/versioned_docs/version-v0.7/concepts/identity-data-model.mdx
index a5b1f3c57..343693a7f 100644
--- a/docs/versioned_docs/version-v0.7/concepts/identity-data-model.mdx
+++ b/docs/versioned_docs/version-v0.7/concepts/identity-data-model.mdx
@@ -172,6 +172,16 @@ switch (session.Identity.SchemaID) {
 }
 ```
 
+:::warning
+
+Please keep in mind that the Identity Model is not a place to store sensitive
+information that should not be visible to the end-user! Users can see traits and
+other data - except credentials - using the `/sessions/whoami` endpoint. The
+Identity Model is not the right place to store data that should be obfuscated
+from the user!
+
+:::
+
 ## JSON Schema Vocabulary Extensions
 
 Because Ory Kratos does not know that a particular field has a system-relevant
diff --git a/driver/config/.schema/config.schema.json b/driver/config/.schema/config.schema.json
index 7bb0979dd..cda192935 100644
--- a/driver/config/.schema/config.schema.json
+++ b/driver/config/.schema/config.schema.json
@@ -72,6 +72,18 @@
         "hook"
       ]
     },
+    "selfServiceRequireVerifiedAddressHook": {
+      "type": "object",
+      "properties": {
+        "hook": {
+          "const": "require_verified_address"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "hook"
+      ]
+    },
     "webHookAuthBasicAuthProperties": {
       "properties": {
         "type": {
@@ -452,7 +464,34 @@
         }
       }
     },
-    "selfServiceAfterLoginMethod": {
+    "selfServiceAfterPasswordLoginMethod": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "default_browser_return_url": {
+          "$ref": "#/definitions/defaultReturnTo"
+        },
+        "hooks": {
+          "type": "array",
+          "items": {
+            "anyOf": [
+              {
+                "$ref": "#/definitions/selfServiceSessionRevokerHook"
+              },
+              {
+                "$ref": "#/definitions/selfServiceRequireVerifiedAddressHook"
+              },
+              {
+                "$ref": "#/definitions/selfServiceWebHook"
+              }
+            ]
+          },
+          "uniqueItems": true,
+          "additionalItems": false
+        }
+      }
+    },
+    "selfServiceAfterOIDCLoginMethod": {
       "type": "object",
       "additionalProperties": false,
       "properties": {
@@ -535,10 +574,10 @@
           "$ref": "#/definitions/defaultReturnTo"
         },
         "password": {
-          "$ref": "#/definitions/selfServiceAfterLoginMethod"
+          "$ref": "#/definitions/selfServiceAfterPasswordLoginMethod"
         },
         "oidc": {
-          "$ref": "#/definitions/selfServiceAfterLoginMethod"
+          "$ref": "#/definitions/selfServiceAfterOIDCLoginMethod"
         },
         "hooks": {
           "$ref": "#/definitions/selfServiceHooks"
diff --git a/driver/registry_default.go b/driver/registry_default.go
index 6b773527f..ac03afb98 100644
--- a/driver/registry_default.go
+++ b/driver/registry_default.go
@@ -75,6 +75,7 @@ type RegistryDefault struct {
 	hookVerifier         *hook.Verifier
 	hookSessionIssuer    *hook.SessionIssuer
 	hookSessionDestroyer *hook.SessionDestroyer
+	hookAddressVerifier  *hook.AddressVerifier
 
 	identityHandler   *identity.Handler
 	identityValidator *identity.Validator
diff --git a/driver/registry_default_hooks.go b/driver/registry_default_hooks.go
index 1d8fc649a..1f85d8a59 100644
--- a/driver/registry_default_hooks.go
+++ b/driver/registry_default_hooks.go
@@ -26,6 +26,13 @@ func (m *RegistryDefault) HookSessionDestroyer() *hook.SessionDestroyer {
 	return m.hookSessionDestroyer
 }
 
+func (m *RegistryDefault) HookAddressVerifier() *hook.AddressVerifier {
+	if m.hookAddressVerifier == nil {
+		m.hookAddressVerifier = hook.NewAddressVerifier()
+	}
+	return m.hookAddressVerifier
+}
+
 func (m *RegistryDefault) WithHooks(hooks map[string]func(config.SelfServiceHook) interface{}) {
 	m.injectedSelfserviceHooks = hooks
 }
@@ -39,6 +46,8 @@ func (m *RegistryDefault) getHooks(credentialsType string, configs []config.Self
 			i = append(i, m.HookSessionDestroyer())
 		case hook.KeyWebHook:
 			i = append(i, hook.NewWebHook(m, h.Config))
+		case hook.KeyAddressVerifier:
+			i = append(i, m.HookAddressVerifier())
 		default:
 			var found bool
 			for name, m := range m.injectedSelfserviceHooks {
diff --git a/internal/.kratos.yaml b/internal/.kratos.yaml
index 52d4e1501..5e3c35d39 100644
--- a/internal/.kratos.yaml
+++ b/internal/.kratos.yaml
@@ -134,6 +134,7 @@ selfservice:
           default_browser_return_url: https://self-service/login/password/return_to
           hooks:
             - hook: revoke_active_sessions
+            - hook: require_verified_address
             - hook: web_hook
               config:
                 url: https://test.kratos.ory.sh/after_login_password_hook
diff --git a/schema/errors.go b/schema/errors.go
index baf82f4d9..453ce4898 100644
--- a/schema/errors.go
+++ b/schema/errors.go
@@ -173,3 +173,13 @@ func NewNoVerificationStrategyResponsible() error {
 		Messages: new(text.Messages).Add(text.NewErrorValidationVerificationNoStrategyFound()),
 	})
 }
+
+func NewAddressNotVerifiedError() error {
+	return errors.WithStack(&ValidationError{
+		ValidationError: &jsonschema.ValidationError{
+			Message:     `account address not yet verified`,
+			InstancePtr: "#/",
+		},
+		Messages: new(text.Messages).Add(text.NewErrorValidationAddressNotVerified()),
+	})
+}
diff --git a/selfservice/flow/login/error.go b/selfservice/flow/login/error.go
index 8783d6d14..1cdef1329 100644
--- a/selfservice/flow/login/error.go
+++ b/selfservice/flow/login/error.go
@@ -22,8 +22,9 @@ import (
 )
 
 var (
-	ErrHookAbortFlow   = errors.New("aborted login hook execution")
-	ErrAlreadyLoggedIn = herodot.ErrBadRequest.WithReason("A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?")
+	ErrHookAbortFlow      = errors.New("aborted login hook execution")
+	ErrAlreadyLoggedIn    = herodot.ErrBadRequest.WithReason("A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?")
+	ErrAddressNotVerified = herodot.ErrBadRequest.WithReason("The email address is not verified yet.")
 )
 
 type (
diff --git a/selfservice/flow/login/handler.go b/selfservice/flow/login/handler.go
index 847e15d90..a7c781a7b 100644
--- a/selfservice/flow/login/handler.go
+++ b/selfservice/flow/login/handler.go
@@ -423,7 +423,6 @@ func (h *Handler) submitFlow(w http.ResponseWriter, r *http.Request, _ httproute
 	}
 
 	var i *identity.Identity
-	var s identity.CredentialsType
 	for _, ss := range h.d.AllLoginStrategies() {
 		interim, err := ss.Login(w, r, f)
 		if errors.Is(err, flow.ErrStrategyNotResponsible) {
@@ -436,7 +435,6 @@ func (h *Handler) submitFlow(w http.ResponseWriter, r *http.Request, _ httproute
 		}
 
 		i = interim
-		s = ss.ID()
 		break
 	}
 
@@ -447,8 +445,12 @@ func (h *Handler) submitFlow(w http.ResponseWriter, r *http.Request, _ httproute
 
 	// TODO Handle n+1 authentication factor
 
-	if err := h.d.LoginHookExecutor().PostLoginHook(w, r, s, f, i); err != nil {
-		h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
+	if err := h.d.LoginHookExecutor().PostLoginHook(w, r, f, i); err != nil {
+		if err == ErrAddressNotVerified {
+			h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, errors.WithStack(schema.NewAddressNotVerifiedError()))
+		} else {
+			h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
+		}
 		return
 	}
 }
diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go
index 5c1e5f3b6..771052db8 100644
--- a/selfservice/flow/login/hook.go
+++ b/selfservice/flow/login/hook.go
@@ -60,7 +60,7 @@ func NewHookExecutor(d executorDependencies) *HookExecutor {
 	return &HookExecutor{d: d}
 }
 
-func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, ct identity.CredentialsType, a *Flow, i *identity.Identity) error {
+func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
 	s, err := session.NewActiveSession(i, e.d.Config(r.Context()), time.Now().UTC())
 	if err != nil {
 		return err
@@ -70,18 +70,18 @@ func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, ct
 	e.d.Logger().
 		WithRequest(r).
 		WithField("identity_id", i.ID).
-		WithField("flow_method", ct).
+		WithField("flow_method", a.Active).
 		Debug("Running ExecuteLoginPostHook.")
-	for k, executor := range e.d.PostLoginHooks(r.Context(), ct) {
+	for k, executor := range e.d.PostLoginHooks(r.Context(), a.Active) {
 		if err := executor.ExecuteLoginPostHook(w, r, a, s); err != nil {
 			if errors.Is(err, ErrHookAbortFlow) {
 				e.d.Logger().
 					WithRequest(r).
 					WithField("executor", fmt.Sprintf("%T", executor)).
 					WithField("executor_position", k).
-					WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), ct))).
+					WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), a.Active))).
 					WithField("identity_id", i.ID).
-					WithField("flow_method", ct).
+					WithField("flow_method", a.Active).
 					Debug("A ExecuteLoginPostHook hook aborted early.")
 				return nil
 			}
@@ -92,9 +92,9 @@ func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, ct
 			WithRequest(r).
 			WithField("executor", fmt.Sprintf("%T", executor)).
 			WithField("executor_position", k).
-			WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), ct))).
+			WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), a.Active))).
 			WithField("identity_id", i.ID).
-			WithField("flow_method", ct).
+			WithField("flow_method", a.Active).
 			Debug("ExecuteLoginPostHook completed successfully.")
 	}
 
@@ -130,7 +130,7 @@ func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, ct
 	}
 
 	return x.SecureContentNegotiationRedirection(w, r, s.Declassify(), a.RequestURL,
-		e.d.Writer(), e.d.Config(r.Context()), x.SecureRedirectOverrideDefaultReturnTo(e.d.Config(r.Context()).SelfServiceFlowLoginReturnTo(ct.String())))
+		e.d.Writer(), e.d.Config(r.Context()), x.SecureRedirectOverrideDefaultReturnTo(e.d.Config(r.Context()).SelfServiceFlowLoginReturnTo(a.Active.String())))
 }
 
 func (e *HookExecutor) PreLoginHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
diff --git a/selfservice/hook/address_verifier.go b/selfservice/hook/address_verifier.go
new file mode 100644
index 000000000..f38713e46
--- /dev/null
+++ b/selfservice/hook/address_verifier.go
@@ -0,0 +1,47 @@
+package hook
+
+import (
+	"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/session"
+)
+
+var _ login.PostHookExecutor = new(SessionDestroyer)
+
+type AddressVerifier struct{}
+
+func NewAddressVerifier() *AddressVerifier {
+	return &AddressVerifier{}
+}
+
+func (e *AddressVerifier) ExecuteLoginPostHook(_ http.ResponseWriter, _ *http.Request, f *login.Flow, s *session.Session) error {
+	// if the login happens using the password method, there must be at least one verified address
+	if f.Active != identity.CredentialsTypePassword {
+		return nil
+	}
+
+	// TODO: can this happen at all?
+	if len(s.Identity.VerifiableAddresses) == 0 {
+		return errors.WithStack(herodot.ErrInternalServerError.WithReason("A misconfiguration prevents login. Expected to find a verification address but this identity does not have one assigned."))
+	}
+
+	addressVerified := false
+	for _, va := range s.Identity.VerifiableAddresses {
+		if va.Verified {
+			addressVerified = true
+			break
+		}
+	}
+
+	if !addressVerified {
+		return login.ErrAddressNotVerified
+	}
+
+	return nil
+}
diff --git a/selfservice/hook/hooks.go b/selfservice/hook/hooks.go
index 2525c0a3e..5f3c4ea8c 100644
--- a/selfservice/hook/hooks.go
+++ b/selfservice/hook/hooks.go
@@ -4,4 +4,5 @@ const (
 	KeySessionIssuer    = "session"
 	KeySessionDestroyer = "revoke_active_sessions"
 	KeyWebHook          = "web_hook"
+	KeyAddressVerifier  = "require_verified_address"
 )
diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go
index 5eda3ca4e..a835752b0 100644
--- a/selfservice/strategy/oidc/strategy_login.go
+++ b/selfservice/strategy/oidc/strategy_login.go
@@ -99,7 +99,7 @@ func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login
 
 	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 {
+			if err = s.d.LoginHookExecutor().PostLoginHook(w, r, a, i); err != nil {
 				return nil, s.handleError(w, r, a, provider.Config().ID, nil, err)
 			}
 			return nil, nil
@@ -153,6 +153,11 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow)
 		return nil, s.handleError(w, r, f, pid, nil, err)
 	}
 
+	f.Active = identity.CredentialsTypeOIDC
+	if err = s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil {
+		return nil, s.handleError(w, r, f, pid, nil, errors.WithStack(herodot.ErrInternalServerError.WithReason("Could not update flow").WithDebug(err.Error())))
+	}
+
 	http.Redirect(w, r, c.AuthCodeURL(state, provider.AuthCodeURLOptions(req)...), http.StatusFound)
 	return nil, errors.WithStack(flow.ErrCompletedByStrategy)
 }
diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go
index 015b73f95..2640d4595 100644
--- a/selfservice/strategy/password/login.go
+++ b/selfservice/strategy/password/login.go
@@ -68,6 +68,11 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow)
 		return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError()))
 	}
 
+	f.Active = identity.CredentialsTypePassword
+	if err = s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil {
+		return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReason("Could not update flow").WithDebug(err.Error())))
+	}
+
 	return i, nil
 }
 
diff --git a/text/message_validation.go b/text/message_validation.go
index 290d0ddd0..9bf88d3bf 100644
--- a/text/message_validation.go
+++ b/text/message_validation.go
@@ -15,6 +15,7 @@ const (
 	ErrorValidationDuplicateCredentials
 	ErrorValidationTOTPVerifierWrong
 	ErrorValidationIdentifierMissing
+	ErrorValidationAddressNotVerified
 )
 
 func NewValidationErrorGeneric(reason string) *Message {
@@ -106,3 +107,11 @@ func NewErrorValidationIdentifierMissing() *Message {
 		Type: Error,
 	}
 }
+
+func NewErrorValidationAddressNotVerified() *Message {
+	return &Message{
+		ID:   ErrorValidationAddressNotVerified,
+		Text: "Account not active yet. Did you forget to verify your email address?",
+		Type: Error,
+	}
+}


Test patch:
diff --git a/driver/config/config_test.go b/driver/config/config_test.go
index 065ce4820..6f7f24c07 100644
--- a/driver/config/config_test.go
+++ b/driver/config/config_test.go
@@ -236,6 +236,7 @@ func TestViperProvider(t *testing.T) {
 					strategy: "password",
 					hooks: []config.SelfServiceHook{
 						{Name: "revoke_active_sessions", Config: json.RawMessage(`{}`)},
+						{Name: "require_verified_address", Config: json.RawMessage(`{}`)},
 						{Name: "web_hook", Config: json.RawMessage(`{"auth":{"config":{"password":"super-secret","user":"test-user"},"type":"basic_auth"},"body":"/path/to/template.jsonnet","method":"POST","url":"https://test.kratos.ory.sh/after_login_password_hook"}`)},
 					},
 				},
diff --git a/driver/registry_default_test.go b/driver/registry_default_test.go
index 386ce0ebf..de0bd5abb 100644
--- a/driver/registry_default_test.go
+++ b/driver/registry_default_test.go
@@ -308,16 +308,31 @@ func TestDriverDefault_Hooks(t *testing.T) {
 				},
 			},
 			{
-				uc: "A revoke_active_sessions hook and a web_hook are configured for password strategy",
+				uc: "Only require_verified_address hook configured for password strategy",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".password.hooks", []map[string]interface{}{
+						{"hook": "require_verified_address"},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []login.PostHookExecutor {
+					return []login.PostHookExecutor{
+						hook.NewAddressVerifier(),
+					}
+				},
+			},
+			{
+				uc: "A revoke_active_sessions hook, require_verified_address hook and a web_hook are configured for password strategy",
 				prep: func(conf *config.Config) {
 					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".password.hooks", []map[string]interface{}{
 						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST", "body": "bar"}},
+						{"hook": "require_verified_address"},
 						{"hook": "revoke_active_sessions"},
 					})
 				},
 				expect: func(reg *driver.RegistryDefault) []login.PostHookExecutor {
 					return []login.PostHookExecutor{
 						hook.NewWebHook(reg, json.RawMessage(`{"body":"bar","method":"POST","url":"foo"}`)),
+						hook.NewAddressVerifier(),
 						hook.NewSessionDestroyer(reg),
 					}
 				},
@@ -343,6 +358,7 @@ func TestDriverDefault_Hooks(t *testing.T) {
 					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".password.hooks", []map[string]interface{}{
 						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "GET"}},
 						{"hook": "revoke_active_sessions"},
+						{"hook": "require_verified_address"},
 					})
 					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".hooks", []map[string]interface{}{
 						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST"}},
@@ -352,6 +368,7 @@ func TestDriverDefault_Hooks(t *testing.T) {
 					return []login.PostHookExecutor{
 						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"foo"}`)),
 						hook.NewSessionDestroyer(reg),
+						hook.NewAddressVerifier(),
 					}
 				},
 			},
diff --git a/selfservice/flow/login/hook_test.go b/selfservice/flow/login/hook_test.go
index d33c2d106..65fc1c669 100644
--- a/selfservice/flow/login/hook_test.go
+++ b/selfservice/flow/login/hook_test.go
@@ -41,9 +41,10 @@ func TestLoginExecutor(t *testing.T) {
 
 				router.GET("/login/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
 					a := login.NewFlow(conf, time.Minute, "", r, ft)
+					a.Active = identity.CredentialsType(strategy)
 					a.RequestURL = x.RequestURL(r).String()
 					testhelpers.SelfServiceHookLoginErrorHandler(t, w, r,
-						reg.LoginHookExecutor().PostLoginHook(w, r, identity.CredentialsType(strategy), a, testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)))
+						reg.LoginHookExecutor().PostLoginHook(w, r, a, testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)))
 				})
 
 				ts := httptest.NewServer(router)
diff --git a/selfservice/hook/address_verifier_test.go b/selfservice/hook/address_verifier_test.go
new file mode 100644
index 000000000..63a689a26
--- /dev/null
+++ b/selfservice/hook/address_verifier_test.go
@@ -0,0 +1,91 @@
+package hook
+
+import (
+	"testing"
+
+	"github.com/gofrs/uuid"
+	"github.com/stretchr/testify/assert"
+
+	"github.com/ory/herodot"
+
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/selfservice/flow/login"
+	"github.com/ory/kratos/session"
+	"github.com/ory/kratos/x"
+)
+
+func TestAddressVerifier(t *testing.T) {
+	verifier := NewAddressVerifier()
+
+	for _, tc := range []struct {
+		flow       *login.Flow
+		neverError bool
+	}{
+		{&login.Flow{Active: identity.CredentialsTypePassword}, false},
+		{&login.Flow{Active: identity.CredentialsTypeOIDC}, true},
+	} {
+		t.Run(tc.flow.Active.String()+" flow", func(t *testing.T) {
+			for _, uc := range []struct {
+				name                string
+				verifiableAddresses []identity.VerifiableAddress
+				expectedError       error
+			}{
+				{
+					name:                "No Verification Address",
+					verifiableAddresses: []identity.VerifiableAddress{},
+					expectedError:       herodot.ErrInternalServerError.WithReason("A misconfiguration prevents login. Expected to find a verification address but this identity does not have one assigned."),
+				},
+				{
+					name: "Single Address Not Verified",
+					verifiableAddresses: []identity.VerifiableAddress{
+						{ID: uuid.UUID{}, Verified: false},
+					},
+					expectedError: login.ErrAddressNotVerified,
+				},
+				{
+					name: "Single Address Verified",
+					verifiableAddresses: []identity.VerifiableAddress{
+						{ID: uuid.UUID{}, Verified: true},
+					},
+				},
+				{
+					name: "Multiple Addresses Verified",
+					verifiableAddresses: []identity.VerifiableAddress{
+						{ID: uuid.UUID{}, Verified: true},
+						{ID: uuid.UUID{}, Verified: true},
+					},
+				},
+				{
+					name: "Multiple Addresses Not Verified",
+					verifiableAddresses: []identity.VerifiableAddress{
+						{ID: uuid.UUID{}, Verified: false},
+						{ID: uuid.UUID{}, Verified: false},
+					},
+					expectedError: login.ErrAddressNotVerified,
+				},
+				{
+					name: "One Address Verified And One Not",
+					verifiableAddresses: []identity.VerifiableAddress{
+						{ID: uuid.UUID{}, Verified: true},
+						{ID: uuid.UUID{}, Verified: false},
+					},
+				},
+			} {
+				t.Run(uc.name, func(t *testing.T) {
+					sessions := &session.Session{
+						ID:       x.NewUUID(),
+						Identity: &identity.Identity{ID: x.NewUUID(), VerifiableAddresses: uc.verifiableAddresses},
+					}
+
+					err := verifier.ExecuteLoginPostHook(nil, nil, tc.flow, sessions)
+
+					if tc.neverError || uc.expectedError == nil {
+						assert.NoError(t, err)
+					} else {
+						assert.ErrorIs(t, err, uc.expectedError)
+					}
+				})
+			}
+		})
+	}
+}
diff --git a/selfservice/strategy/password/login_test.go b/selfservice/strategy/password/login_test.go
index fb172450e..a70128916 100644
--- a/selfservice/strategy/password/login_test.go
+++ b/selfservice/strategy/password/login_test.go
@@ -61,8 +61,9 @@ func TestCompleteLogin(t *testing.T) {
 
 	createIdentity := func(identifier, password string) {
 		p, _ := reg.Hasher().Generate(context.Background(), []byte(password))
+		iId := x.NewUUID()
 		require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &identity.Identity{
-			ID:     x.NewUUID(),
+			ID:     iId,
 			Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)),
 			Credentials: map[identity.CredentialsType]identity.Credentials{
 				identity.CredentialsTypePassword: {
@@ -71,6 +72,15 @@ func TestCompleteLogin(t *testing.T) {
 					Config:      sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
 				},
 			},
+			VerifiableAddresses: []identity.VerifiableAddress{
+				{
+					ID:         x.NewUUID(),
+					Value:      identifier,
+					Verified:   false,
+					CreatedAt:  time.Now(),
+					IdentityID: iId,
+				},
+			},
 		}))
 	}
 
@@ -642,4 +652,40 @@ func TestCompleteLogin(t *testing.T) {
 
 		assert.Equal(t, identifier, gjson.Get(body2, "identity.traits.subject").String(), "%s", body2)
 	})
+
+	t.Run("should fail as email is not yet verified", func(t *testing.T) {
+		conf.MustSet(config.ViperKeySelfServiceLoginAfter+".password.hooks", []map[string]interface{}{
+			{"hook": "require_verified_address"},
+		})
+
+		identifier, pwd := x.NewUUID().String(), "password"
+		createIdentity(identifier, pwd)
+
+		var values = func(v url.Values) {
+			v.Set("method", "password")
+			v.Set("password_identifier", identifier)
+			v.Set("password", pwd)
+		}
+
+		var check = func(t *testing.T, body string) {
+			assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
+			assert.Contains(t, gjson.Get(body, "ui.action").String(), publicTS.URL+login.RouteSubmitFlow, "%s", body)
+
+			ensureFieldsExist(t, []byte(body))
+			assert.Equal(t,
+				errorsx.Cause(schema.NewAddressNotVerifiedError()).(*schema.ValidationError).Messages[0].Text,
+				gjson.Get(body, "ui.messages.0.text").String(),
+				"%s", body,
+			)
+		}
+
+		t.Run("type=browser", func(t *testing.T) {
+			check(t, expectValidationError(t, false, false, false, values))
+		})
+
+		t.Run("type=api", func(t *testing.T) {
+			check(t, expectValidationError(t, true, false, false, values))
+		})
+
+	})
 }
diff --git a/test/e2e/cypress/integration/profiles/verification/login/errors.spec.js b/test/e2e/cypress/integration/profiles/verification/login/errors.spec.js
new file mode 100644
index 000000000..ae873f2cc
--- /dev/null
+++ b/test/e2e/cypress/integration/profiles/verification/login/errors.spec.js
@@ -0,0 +1,29 @@
+import { APP_URL, gen } from '../../../../helpers'
+
+context('Verification Profile', () => {
+  describe('Login', () => {
+    before(() => {
+      cy.useConfigProfile('verification')
+      cy.enableLoginForVerifiedAddressOnly()
+    })
+
+    describe('error flow', () => {
+      it('Is unable to login as long as the email is not verified', () => {
+        cy.deleteMail()
+
+        const identity = gen.identity()
+        cy.register(identity)
+
+        cy.visit(APP_URL + '/')
+
+        cy.get('input[name="password_identifier"]').type(identity.email)
+        cy.get('input[name="password"]').type(identity.password)
+        cy.get('button[value="password"]').click()
+
+        cy.get('.error').contains('Account not active yet')
+
+        cy.getCookie('ory_kratos_session').should('not.exist')
+      })
+    })
+  })
+})
diff --git a/test/e2e/cypress/integration/profiles/verification/login/success.spec.js b/test/e2e/cypress/integration/profiles/verification/login/success.spec.js
new file mode 100644
index 000000000..a7848e698
--- /dev/null
+++ b/test/e2e/cypress/integration/profiles/verification/login/success.spec.js
@@ -0,0 +1,28 @@
+import { APP_URL, gen } from '../../../../helpers'
+
+context('Verification Profile', () => {
+  describe('Login', () => {
+    before(() => {
+      cy.useConfigProfile('verification')
+      cy.enableLoginForVerifiedAddressOnly()
+    })
+
+    describe('success flow', () => {
+      it('Is able to login after successful email verification', () => {
+        cy.deleteMail()
+
+        const identity = gen.identity()
+        cy.register(identity)
+        cy.performEmailVerification({ expect: { email: identity.email } })
+
+        cy.visit(APP_URL + '/')
+
+        cy.get('input[name="password_identifier"]').type(identity.email)
+        cy.get('input[name="password"]').type(identity.password)
+        cy.get('button[value="password"]').click()
+
+        cy.getCookie('ory_kratos_session').should('exist')
+      })
+    })
+  })
+})
diff --git a/test/e2e/cypress/support/commands.js b/test/e2e/cypress/support/commands.js
index 5e88c38e8..b6f5f8572 100644
--- a/test/e2e/cypress/support/commands.js
+++ b/test/e2e/cypress/support/commands.js
@@ -61,12 +61,7 @@ const updateConfigFile = (cb) => {
   cy.wait(100)
 }
 
-let previousProfile = ''
 Cypress.Commands.add('useConfigProfile', (profile) => {
-  if (profile === previousProfile) {
-    return
-  }
-
   cy.readFile(`test/e2e/kratos.${profile}.yml`).then((contents) =>
     cy.writeFile(configFile, contents)
   )
@@ -117,6 +112,15 @@ Cypress.Commands.add('longRecoveryLifespan', ({} = {}) => {
   })
 })
 
+Cypress.Commands.add('enableLoginForVerifiedAddressOnly', () => {
+  updateConfigFile((config) => {
+    config.selfservice.flows.login['after'] = {
+      password: { hooks: [{ hook: 'require_verified_address' }] }
+    }
+    return config
+  })
+})
+
 Cypress.Commands.add('shortRecoveryLifespan', ({} = {}) => {
   updateConfigFile((config) => {
     config.selfservice.flows.recovery.lifespan = '1ms'
diff --git a/test/schema/fixtures/config.schema.test.success/selfServiceAfterLogin.full.yaml b/test/schema/fixtures/config.schema.test.success/selfServiceAfterLogin.full.yaml
index 6cf051d92..d570be020 100644
--- a/test/schema/fixtures/config.schema.test.success/selfServiceAfterLogin.full.yaml
+++ b/test/schema/fixtures/config.schema.test.success/selfServiceAfterLogin.full.yaml
@@ -1,3 +1,3 @@
 default_browser_return_url: "#/definitions/defaultReturnTo"
-password: "#/definitions/selfServiceAfterLoginMethod"
-oidc: "#/definitions/selfServiceAfterLoginMethod"
+password: "#/definitions/selfServiceAfterPasswordLoginMethod"
+oidc: "#/definitions/selfServiceAfterOIDCLoginMethod"
diff --git a/test/schema/fixtures/config.schema.test.success/selfServiceAfterLoginMethod.full.yaml b/test/schema/fixtures/config.schema.test.success/selfServiceAfterOIDCLoginMethod.full.yaml
similarity index 100%
rename from test/schema/fixtures/config.schema.test.success/selfServiceAfterLoginMethod.full.yaml
rename to test/schema/fixtures/config.schema.test.success/selfServiceAfterOIDCLoginMethod.full.yaml
diff --git a/test/schema/fixtures/config.schema.test.success/selfServiceAfterLoginMethod.required.yaml b/test/schema/fixtures/config.schema.test.success/selfServiceAfterOIDCLoginMethod.required.yaml
similarity index 100%
rename from test/schema/fixtures/config.schema.test.success/selfServiceAfterLoginMethod.required.yaml
rename to test/schema/fixtures/config.schema.test.success/selfServiceAfterOIDCLoginMethod.required.yaml
diff --git a/test/schema/fixtures/config.schema.test.success/selfServiceAfterPasswordLoginMethod.full.yaml b/test/schema/fixtures/config.schema.test.success/selfServiceAfterPasswordLoginMethod.full.yaml
new file mode 100644
index 000000000..e4daaadec
--- /dev/null
+++ b/test/schema/fixtures/config.schema.test.success/selfServiceAfterPasswordLoginMethod.full.yaml
@@ -0,0 +1,4 @@
+default_browser_return_url: "#/definitions/defaultReturnTo"
+hooks:
+  - "#/definitions/selfServiceSessionRevokerHook"
+  - "#/definitions/selfServiceRequireVerifiedAddressHook"
diff --git a/test/schema/fixtures/config.schema.test.success/selfServiceAfterPasswordLoginMethod.required.yaml b/test/schema/fixtures/config.schema.test.success/selfServiceAfterPasswordLoginMethod.required.yaml
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/test/schema/fixtures/config.schema.test.success/selfServiceAfterPasswordLoginMethod.required.yaml
@@ -0,0 +1,1 @@
+{}