Repository: ory/kratos
Pull request #2706 (2022-09-01 09:51:41)

feat: add pre-hooks to settings, verification, recovery
Adds pre-hooks to settings, verification, and recovery flows.

codecov[bot]: # [Codecov](https://codecov.io/gh/ory/kratos/pull/2706?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) Report
> Merging [#2706](https://codecov.io/gh/ory/kratos/pull/2706?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) (61b9005) into [master](https://codecov.io/gh/ory/kratos/commit/0856bd719b7e06a6d2163bf428ff6513d86376db?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) (0856bd7) will **decrease** coverage by `0.06%`.
> The diff coverage is `62.65%`.

```diff
@@            Coverage Diff             @@
##           master    #2706      +/-   ##
==========================================
- Coverage   75.13%   75.07%   -0.07%     
==========================================
  Files         293      293              
  Lines       16740    16818      +78     
==========================================
+ Hits        12578    12626      +48     
- Misses       3197     3227      +30     
  Partials      965      965              
```


| [Impacted Files](https://codecov.io/gh/ory/kratos/pull/2706?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) | Coverage Δ | |
|---|---|---|
| [cmd/cleanup/sql.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-Y21kL2NsZWFudXAvc3FsLmdv) | `96.00% <ø> (ø)` | |
| [cmd/migrate/sql.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-Y21kL21pZ3JhdGUvc3FsLmdv) | `100.00% <ø> (ø)` | |
| [examples/go/selfservice/error/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2VsZnNlcnZpY2UvZXJyb3IvbWFpbi5nbw==) | `50.00% <ø> (ø)` | |
| [examples/go/selfservice/login/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2VsZnNlcnZpY2UvbG9naW4vbWFpbi5nbw==) | `78.94% <ø> (ø)` | |
| [examples/go/selfservice/logout/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2VsZnNlcnZpY2UvbG9nb3V0L21haW4uZ28=) | `66.66% <ø> (ø)` | |
| [examples/go/selfservice/recovery/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2VsZnNlcnZpY2UvcmVjb3ZlcnkvbWFpbi5nbw==) | `73.33% <ø> (ø)` | |
| [examples/go/selfservice/registration/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2VsZnNlcnZpY2UvcmVnaXN0cmF0aW9uL21haW4uZ28=) | `77.77% <ø> (ø)` | |
| [examples/go/selfservice/settings/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2VsZnNlcnZpY2Uvc2V0dGluZ3MvbWFpbi5nbw==) | `80.00% <ø> (ø)` | |
| [examples/go/selfservice/verification/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2VsZnNlcnZpY2UvdmVyaWZpY2F0aW9uL21haW4uZ28=) | `73.33% <ø> (ø)` | |
| [examples/go/session/tosession/main.go](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory#diff-ZXhhbXBsZXMvZ28vc2Vzc2lvbi90b3Nlc3Npb24vbWFpbi5nbw==) | `75.00% <ø> (ø)` | |
| ... and [27 more](https://codecov.io/gh/ory/kratos/pull/2706/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ory)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f2cd8b81..161c153ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
 <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
 **Table of Contents**
 
-- [ (2022-08-24)](#2022-08-24)
+- [ (2022-09-02)](#2022-09-02)
   - [Breaking Changes](#breaking-changes)
     - [Bug Fixes](#bug-fixes)
     - [Code Refactoring](#code-refactoring)
@@ -264,10 +264,13 @@
 
 <!-- END doctoc generated TOC please keep comment here to allow auto update -->
 
-# [](https://github.com/ory/kratos/compare/v0.10.1...v) (2022-08-24)
+# [](https://github.com/ory/kratos/compare/v0.10.1...v) (2022-09-02)
 
 ## Breaking Changes
 
+This patch invalidates recovery flows initiated using the Admin API. Please
+re-generate any admin-generated recovery flows and tokens.
+
 This is a breaking change, as it removes the `courier.message_ttl` config key
 and replaces it with a counter `courier.message_retries`.
 
@@ -307,6 +310,9 @@ SDK Method `getJsonSchema` was renamed to `getIdentitySchema`.
   ([#2613](https://github.com/ory/kratos/issues/2613))
   ([29aa3b6](https://github.com/ory/kratos/commit/29aa3b6c37b3a173dcfeb02fdad4abc83774bc0b)),
   closes [#2591](https://github.com/ory/kratos/issues/2591)
+- Do not invalidate recovery addr on update
+  ([#2699](https://github.com/ory/kratos/issues/2699))
+  ([1689bb9](https://github.com/ory/kratos/commit/1689bb9f0a52387f699568da6bc773929b1201ae))
 - **docker:** Add missing dependencies
   ([#2643](https://github.com/ory/kratos/issues/2643))
   ([c589520](https://github.com/ory/kratos/commit/c589520ff865cefdb287e597b9e858851a778755))
@@ -323,6 +329,8 @@ SDK Method `getJsonSchema` was renamed to `getIdentitySchema`.
   ([#2517](https://github.com/ory/kratos/issues/2517))
   ([c058e23](https://github.com/ory/kratos/commit/c058e23599d994e12b676e87f7282c1f2b2e089c)),
   closes [#2426](https://github.com/ory/kratos/issues/2426)
+- Ignore commata in HIBP response
+  ([0856bd7](https://github.com/ory/kratos/commit/0856bd719b7e06a6d2163bf428ff6513d86376db))
 - Ignore CSRF for session extension on public route
   ([866b472](https://github.com/ory/kratos/commit/866b472750fba7bf498d359796f24867af7270ad))
 - Ignore error explicitly
@@ -330,6 +338,16 @@ SDK Method `getJsonSchema` was renamed to `getIdentitySchema`.
 - Improve migration status speed
   ([#2637](https://github.com/ory/kratos/issues/2637))
   ([a2e3c41](https://github.com/ory/kratos/commit/a2e3c41f9e513e1de47f6320f6a10acd1fed5eea))
+- Include flow id in use recovery token query
+  ([#2679](https://github.com/ory/kratos/issues/2679))
+  ([d56586b](https://github.com/ory/kratos/commit/d56586b028d79387886f880c1455edb5e4df2209)):
+
+  This PR adds the `selfservice_recovery_flow_id` to the query used when "using"
+  a token in the recovery flow.
+
+  This PR also adds a new enum field for `identity_recovery_tokens` to
+  distinguish the two flows: admin versus self-service recovery.
+
 - Make hydra consistently localhost
   ([70211a1](https://github.com/ory/kratos/commit/70211a17a452d5ced8317822afda3f8e6185cc71))
 - Make ID field in VerifiableAddress struct optional
@@ -338,6 +356,8 @@ SDK Method `getJsonSchema` was renamed to `getIdentitySchema`.
   closes [#2506](https://github.com/ory/kratos/issues/2506)
 - Make servicelocator explicit
   ([4f841da](https://github.com/ory/kratos/commit/4f841dae5423acf3514d50add9e99d28bc339fbb))
+- Make swagger/openapi go 1.19 compatible
+  ([fec6772](https://github.com/ory/kratos/commit/fec6772739129e0d5bb4103c717b1ac60df45aa8))
 - Mark gosec false positives
   ([13eaddb](https://github.com/ory/kratos/commit/13eaddb7babe630750361c6d8f3ffc736898ddec))
 - Metadata should not be required
@@ -439,6 +459,8 @@ SDK Method `getJsonSchema` was renamed to `getIdentitySchema`.
 - Add identity id to "account disabled" error
   ([#2557](https://github.com/ory/kratos/issues/2557))
   ([f09b1b3](https://github.com/ory/kratos/commit/f09b1b3701c6deda4d25cebb7ccf2e97089be32a))
+- Add missing config entry
+  ([8fe9de6](https://github.com/ory/kratos/commit/8fe9de6d60a381611e07226614241a83b0010126))
 - Add PATCH to adminUpdateIdentity
   ([#2380](https://github.com/ory/kratos/issues/2380))
   ([#2471](https://github.com/ory/kratos/issues/2471))
@@ -470,6 +492,9 @@ SDK Method `getJsonSchema` was renamed to `getIdentitySchema`.
   `courier.message_retries` option to limit how often the sending of a message
   is retried before it is marked as `abandoned`.
 
+- Support ip exceptions
+  ([de46c08](https://github.com/ory/kratos/commit/de46c08534dfae6165f6a570cc59829f367c0b57))
+
 ### Reverts
 
 - Revert "autogen(openapi): regenerate swagger spec and internal client"
diff --git a/driver/config/config.go b/driver/config/config.go
index d5e7d9455..ab0449a5c 100644
--- a/driver/config/config.go
+++ b/driver/config/config.go
@@ -129,10 +129,12 @@ const (
 	ViperKeySelfServiceLogoutBrowserDefaultReturnTo          = "selfservice.flows.logout.after." + DefaultBrowserReturnURL
 	ViperKeySelfServiceSettingsURL                           = "selfservice.flows.settings.ui_url"
 	ViperKeySelfServiceSettingsAfter                         = "selfservice.flows.settings.after"
+	ViperKeySelfServiceSettingsBeforeHooks                   = "selfservice.flows.settings.before.hooks"
 	ViperKeySelfServiceSettingsRequestLifespan               = "selfservice.flows.settings.lifespan"
 	ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter = "selfservice.flows.settings.privileged_session_max_age"
 	ViperKeySelfServiceSettingsRequiredAAL                   = "selfservice.flows.settings.required_aal"
 	ViperKeySelfServiceRecoveryAfter                         = "selfservice.flows.recovery.after"
+	ViperKeySelfServiceRecoveryBeforeHooks                   = "selfservice.flows.recovery.before.hooks"
 	ViperKeySelfServiceRecoveryEnabled                       = "selfservice.flows.recovery.enabled"
 	ViperKeySelfServiceRecoveryUI                            = "selfservice.flows.recovery.ui_url"
 	ViperKeySelfServiceRecoveryRequestLifespan               = "selfservice.flows.recovery.lifespan"
@@ -142,6 +144,7 @@ const (
 	ViperKeySelfServiceVerificationRequestLifespan           = "selfservice.flows.verification.lifespan"
 	ViperKeySelfServiceVerificationBrowserDefaultReturnTo    = "selfservice.flows.verification.after." + DefaultBrowserReturnURL
 	ViperKeySelfServiceVerificationAfter                     = "selfservice.flows.verification.after"
+	ViperKeySelfServiceVerificationBeforeHooks               = "selfservice.flows.verification.before.hooks"
 	ViperKeyDefaultIdentitySchemaID                          = "identity.default_schema_id"
 	ViperKeyIdentitySchemas                                  = "identity.schemas"
 	ViperKeyHasherAlgorithm                                  = "hashers.algorithm"
@@ -623,6 +626,18 @@ func (p *Config) SelfServiceFlowLoginBeforeHooks(ctx context.Context) []SelfServ
 	return p.selfServiceHooks(ctx, ViperKeySelfServiceLoginBeforeHooks)
 }
 
+func (p *Config) SelfServiceFlowRecoveryBeforeHooks(ctx context.Context) []SelfServiceHook {
+	return p.selfServiceHooks(ctx, ViperKeySelfServiceRecoveryBeforeHooks)
+}
+
+func (p *Config) SelfServiceFlowVerificationBeforeHooks(ctx context.Context) []SelfServiceHook {
+	return p.selfServiceHooks(ctx, ViperKeySelfServiceVerificationBeforeHooks)
+}
+
+func (p *Config) SelfServiceFlowSettingsBeforeHooks(ctx context.Context) []SelfServiceHook {
+	return p.selfServiceHooks(ctx, ViperKeySelfServiceSettingsBeforeHooks)
+}
+
 func (p *Config) SelfServiceFlowRegistrationBeforeHooks(ctx context.Context) []SelfServiceHook {
 	return p.selfServiceHooks(ctx, ViperKeySelfServiceRegistrationBeforeHooks)
 }
diff --git a/driver/registry_default_recovery.go b/driver/registry_default_recovery.go
index 6981edfbd..eddd170e8 100644
--- a/driver/registry_default_recovery.go
+++ b/driver/registry_default_recovery.go
@@ -50,6 +50,15 @@ func (m *RegistryDefault) RecoveryExecutor() *recovery.HookExecutor {
 	return m.selfserviceRecoveryExecutor
 }
 
+func (m *RegistryDefault) PreRecoveryHooks(ctx context.Context) (b []recovery.PreHookExecutor) {
+	for _, v := range m.getHooks("", m.Config().SelfServiceFlowRecoveryBeforeHooks(ctx)) {
+		if hook, ok := v.(recovery.PreHookExecutor); ok {
+			b = append(b, hook)
+		}
+	}
+	return
+}
+
 func (m *RegistryDefault) PostRecoveryHooks(ctx context.Context) (b []recovery.PostHookExecutor) {
 	for _, v := range m.getHooks(config.HookGlobal, m.Config().SelfServiceFlowRecoveryAfterHooks(ctx, config.HookGlobal)) {
 		if hook, ok := v.(recovery.PostHookExecutor); ok {
diff --git a/driver/registry_default_settings.go b/driver/registry_default_settings.go
index 567c06959..22e6ad509 100644
--- a/driver/registry_default_settings.go
+++ b/driver/registry_default_settings.go
@@ -16,6 +16,15 @@ func (m *RegistryDefault) PostSettingsPrePersistHooks(ctx context.Context, setti
 	return
 }
 
+func (m *RegistryDefault) PreSettingsHooks(ctx context.Context) (b []settings.PreHookExecutor) {
+	for _, v := range m.getHooks("", m.Config().SelfServiceFlowSettingsBeforeHooks(ctx)) {
+		if hook, ok := v.(settings.PreHookExecutor); ok {
+			b = append(b, hook)
+		}
+	}
+	return
+}
+
 func (m *RegistryDefault) PostSettingsPostPersistHooks(ctx context.Context, settingsType string) (b []settings.PostHookPostPersistExecutor) {
 	initialHookCount := 0
 	if m.Config().SelfServiceFlowVerificationEnabled(ctx) {
diff --git a/driver/registry_default_verify.go b/driver/registry_default_verify.go
index 7fdca0f71..0cb1a8ab2 100644
--- a/driver/registry_default_verify.go
+++ b/driver/registry_default_verify.go
@@ -73,6 +73,15 @@ func (m *RegistryDefault) VerificationExecutor() *verification.HookExecutor {
 	return m.selfserviceVerificationExecutor
 }
 
+func (m *RegistryDefault) PreVerificationHooks(ctx context.Context) (b []verification.PreHookExecutor) {
+	for _, v := range m.getHooks("", m.Config().SelfServiceFlowVerificationBeforeHooks(ctx)) {
+		if hook, ok := v.(verification.PreHookExecutor); ok {
+			b = append(b, hook)
+		}
+	}
+	return
+}
+
 func (m *RegistryDefault) PostVerificationHooks(ctx context.Context) (b []verification.PostHookExecutor) {
 	for _, v := range m.getHooks(config.HookGlobal, m.Config().SelfServiceFlowVerificationAfterHooks(ctx, config.HookGlobal)) {
 		if hook, ok := v.(verification.PostHookExecutor); ok {
diff --git a/embedx/config.schema.json b/embedx/config.schema.json
index 0fb733ca9..423482e97 100644
--- a/embedx/config.schema.json
+++ b/embedx/config.schema.json
@@ -790,6 +790,33 @@
         }
       }
     },
+    "selfServiceBeforeSettings": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
+        }
+      }
+    },
+    "selfServiceBeforeRecovery": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
+        }
+      }
+    },
+    "selfServiceBeforeVerification": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
+        }
+      }
+    },
     "selfServiceAfterRegistration": {
       "type": "object",
       "additionalProperties": false,
@@ -1023,6 +1050,9 @@
                 },
                 "after": {
                   "$ref": "#/definitions/selfServiceAfterSettings"
+                },
+                "before": {
+                  "$ref": "#/definitions/selfServiceBeforeSettings"
                 }
               }
             },
@@ -1146,6 +1176,9 @@
                     "1m",
                     "1s"
                   ]
+                },
+                "before": {
+                  "$ref": "#/definitions/selfServiceBeforeVerification"
                 }
               }
             },
@@ -1184,6 +1217,9 @@
                     "1m",
                     "1s"
                   ]
+                },
+                "before": {
+                  "$ref": "#/definitions/selfServiceBeforeRecovery"
                 }
               }
             },
diff --git a/go.mod b/go.mod
index c6a205006..e3ddbb6d0 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/ory/kratos
 
-go 1.17
+go 1.18
 
 replace (
 	github.com/bradleyjkemp/cupaloy/v2 => github.com/aeneasr/cupaloy/v2 v2.6.1-0.20210924214125-3dfdd01210a3
diff --git a/identity/identity_recovery.go b/identity/identity_recovery.go
index 0a3a2b662..234b0341d 100644
--- a/identity/identity_recovery.go
+++ b/identity/identity_recovery.go
@@ -2,6 +2,7 @@ package identity
 
 import (
 	"context"
+	"fmt"
 	"time"
 
 	"github.com/gofrs/uuid"
@@ -55,6 +56,11 @@ func (a RecoveryAddress) ValidateNID() error {
 	return nil
 }
 
+// Hash returns a unique string representation for the recovery address.
+func (a RecoveryAddress) Hash() string {
+	return fmt.Sprintf("%v|%v|%v|%v", a.Value, a.Via, a.IdentityID, a.NID)
+}
+
 func NewRecoveryEmailAddress(
 	value string,
 	identity uuid.UUID,
diff --git a/identity/identity_verification.go b/identity/identity_verification.go
index 0fc757d14..d1e6e55c3 100644
--- a/identity/identity_verification.go
+++ b/identity/identity_verification.go
@@ -2,6 +2,7 @@ package identity
 
 import (
 	"context"
+	"fmt"
 	"time"
 
 	"github.com/gofrs/uuid"
@@ -129,3 +130,8 @@ func (a VerifiableAddress) GetNID() uuid.UUID {
 func (a VerifiableAddress) ValidateNID() error {
 	return nil
 }
+
+// Hash returns a unique string representation for the recovery address.
+func (a VerifiableAddress) Hash() string {
+	return fmt.Sprintf("%v|%v|%v|%v|%v|%v", a.Value, a.Verified, a.Via, a.Status, a.IdentityID, a.NID)
+}
diff --git a/package-lock.json b/package-lock.json
index cb26077ca..6429c6d85 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
 {
-  "name": "tmp.T01PPIJfY2",
+  "name": "kratos",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
@@ -5088,7 +5088,8 @@
       "version": "7.5.7",
       "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz",
       "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "y18n": {
       "version": "5.0.8",
diff --git a/persistence/sql/persister_identity.go b/persistence/sql/persister_identity.go
index 22e13f1b5..d6bdba318 100644
--- a/persistence/sql/persister_identity.go
+++ b/persistence/sql/persister_identity.go
@@ -192,9 +192,6 @@ func (p *Persister) createVerifiableAddresses(ctx context.Context, i *identity.I
 	defer span.End()
 
 	for k := range i.VerifiableAddresses {
-		i.VerifiableAddresses[k].IdentityID = i.ID
-		i.VerifiableAddresses[k].NID = p.NetworkID(ctx)
-		i.VerifiableAddresses[k].Value = stringToLowerTrim(i.VerifiableAddresses[k].Value)
 		if err := p.GetConnection(ctx).Create(&i.VerifiableAddresses[k]); err != nil {
 			return err
 		}
@@ -202,14 +199,76 @@ func (p *Persister) createVerifiableAddresses(ctx context.Context, i *identity.I
 	return nil
 }
 
+func updateAssociation[T interface {
+	Hash() string
+}](ctx context.Context, p *Persister, i *identity.Identity, inID []T) error {
+	var inDB []T
+	if err := p.GetConnection(ctx).
+		Where("identity_id = ? AND nid = ?", i.ID, p.NetworkID(ctx)).
+		Order("id ASC").
+		All(&inDB); err != nil {
+
+		return sqlcon.HandleError(err)
+	}
+
+	newAssocs := make(map[string]*T)
+	oldAssocs := make(map[string]*T)
+	for i, a := range inID {
+		newAssocs[a.Hash()] = &inID[i]
+	}
+	for i, a := range inDB {
+		oldAssocs[a.Hash()] = &inDB[i]
+	}
+
+	// Subtle: we delete the old associations from the DB first, because else
+	// they could cause UNIQUE constraints to fail on insert.
+	for h, a := range oldAssocs {
+		if _, found := newAssocs[h]; found {
+			newAssocs[h] = nil // Ignore associations that are already in the db.
+		} else {
+			if err := p.GetConnection(ctx).Destroy(a); err != nil {
+				return sqlcon.HandleError(err)
+			}
+		}
+	}
+
+	for _, a := range newAssocs {
+		if a != nil {
+			if err := p.GetConnection(ctx).Create(a); err != nil {
+				return sqlcon.HandleError(err)
+			}
+		}
+	}
+
+	return nil
+}
+
+func (p *Persister) normalizeAllAddressess(ctx context.Context, id *identity.Identity) {
+	p.normalizeRecoveryAddresses(ctx, id)
+	p.normalizeVerifiableAddresses(ctx, id)
+}
+
+func (p *Persister) normalizeVerifiableAddresses(ctx context.Context, id *identity.Identity) {
+	for k := range id.VerifiableAddresses {
+		id.VerifiableAddresses[k].IdentityID = id.ID
+		id.VerifiableAddresses[k].NID = p.NetworkID(ctx)
+		id.VerifiableAddresses[k].Value = stringToLowerTrim(id.VerifiableAddresses[k].Value)
+	}
+}
+
+func (p *Persister) normalizeRecoveryAddresses(ctx context.Context, id *identity.Identity) {
+	for k := range id.RecoveryAddresses {
+		id.RecoveryAddresses[k].IdentityID = id.ID
+		id.RecoveryAddresses[k].NID = p.NetworkID(ctx)
+		id.RecoveryAddresses[k].Value = stringToLowerTrim(id.RecoveryAddresses[k].Value)
+	}
+}
+
 func (p *Persister) createRecoveryAddresses(ctx context.Context, i *identity.Identity) error {
 	ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.createRecoveryAddresses")
 	defer span.End()
 
 	for k := range i.RecoveryAddresses {
-		i.RecoveryAddresses[k].IdentityID = i.ID
-		i.RecoveryAddresses[k].NID = p.NetworkID(ctx)
-		i.RecoveryAddresses[k].Value = stringToLowerTrim(i.RecoveryAddresses[k].Value)
 		if err := p.GetConnection(ctx).Create(&i.RecoveryAddresses[k]); err != nil {
 			return err
 		}
@@ -285,6 +344,8 @@ func (p *Persister) CreateIdentity(ctx context.Context, i *identity.Identity) er
 			return sqlcon.HandleError(err)
 		}
 
+		p.normalizeAllAddressess(ctx, i)
+
 		if err := p.createVerifiableAddresses(ctx, i); err != nil {
 			return sqlcon.HandleError(err)
 		}
@@ -350,27 +411,25 @@ func (p *Persister) UpdateIdentity(ctx context.Context, i *identity.Identity) er
 			return sql.ErrNoRows
 		}
 
-		for _, tn := range []string{
-			new(identity.Credentials).TableName(ctx),
-			new(identity.VerifiableAddress).TableName(ctx),
-			new(identity.RecoveryAddress).TableName(ctx),
-		} {
-			/* #nosec G201 TableName is static */
-			if err := tx.RawQuery(fmt.Sprintf(
-				`DELETE FROM %s WHERE identity_id = ? AND nid = ?`, tn), i.ID, p.NetworkID(ctx)).Exec(); err != nil {
-				return err
-			}
+		p.normalizeAllAddressess(ctx, i)
+		if err := updateAssociation(ctx, p, i, i.RecoveryAddresses); err != nil {
+			return err
 		}
-
-		if err := p.update(WithTransaction(ctx, tx), i); err != nil {
+		if err := updateAssociation(ctx, p, i, i.VerifiableAddresses); err != nil {
 			return err
 		}
 
-		if err := p.createVerifiableAddresses(ctx, i); err != nil {
-			return err
+		/* #nosec G201 TableName is static */
+		if err := tx.RawQuery(
+			fmt.Sprintf(
+				`DELETE FROM %s WHERE identity_id = ? AND nid = ?`,
+				new(identity.Credentials).TableName(ctx)),
+			i.ID, p.NetworkID(ctx)).Exec(); err != nil {
+
+			return sqlcon.HandleError(err)
 		}
 
-		if err := p.createRecoveryAddresses(ctx, i); err != nil {
+		if err := p.update(WithTransaction(ctx, tx), i); err != nil {
 			return err
 		}
 
diff --git a/selfservice/flow/recovery/handler.go b/selfservice/flow/recovery/handler.go
index 1da77912a..664f2d9a7 100644
--- a/selfservice/flow/recovery/handler.go
+++ b/selfservice/flow/recovery/handler.go
@@ -51,6 +51,7 @@ type (
 		x.CSRFProvider
 		config.Provider
 		ErrorHandlerProvider
+		HookExecutorProvider
 	}
 	Handler struct {
 		d handlerDependencies
@@ -127,6 +128,11 @@ func (h *Handler) initAPIFlow(w http.ResponseWriter, r *http.Request, _ httprout
 		return
 	}
 
+	if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, req); err != nil {
+		h.d.Writer().WriteError(w, r, err)
+		return
+	}
+
 	if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), req); err != nil {
 		h.d.Writer().WriteError(w, r, err)
 		return
@@ -178,6 +184,11 @@ func (h *Handler) initBrowserFlow(w http.ResponseWriter, r *http.Request, _ http
 		return
 	}
 
+	if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, f); err != nil {
+		h.d.Writer().WriteError(w, r, err)
+		return
+	}
+
 	if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), f); err != nil {
 		h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
 		return
diff --git a/selfservice/flow/recovery/hook.go b/selfservice/flow/recovery/hook.go
index 9fe1fb20d..7c75f2f4b 100644
--- a/selfservice/flow/recovery/hook.go
+++ b/selfservice/flow/recovery/hook.go
@@ -14,12 +14,18 @@ import (
 )
 
 type (
+	PreHookExecutor interface {
+		ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
+	}
+	PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error
+
 	PostHookExecutor interface {
 		ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
 	}
 	PostHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
 
 	HooksProvider interface {
+		PreRecoveryHooks(ctx context.Context) []PreHookExecutor
 		PostRecoveryHooks(ctx context.Context) []PostHookExecutor
 	}
 )
@@ -32,6 +38,10 @@ func PostHookRecoveryExecutorNames(e []PostHookExecutor) []string {
 	return names
 }
 
+func (f PreHookExecutorFunc) ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
+	return f(w, r, a)
+}
+
 func (f PostHookExecutorFunc) ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
 	return f(w, r, a, s)
 }
@@ -92,3 +102,13 @@ func (e *HookExecutor) PostRecoveryHook(w http.ResponseWriter, r *http.Request,
 
 	return nil
 }
+
+func (e *HookExecutor) PreRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
+	for _, executor := range e.d.PreRecoveryHooks(r.Context()) {
+		if err := executor.ExecuteRecoveryPreHook(w, r, a); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go
index 2974f56fa..2c017c0ed 100644
--- a/selfservice/flow/registration/hook.go
+++ b/selfservice/flow/registration/hook.go
@@ -52,9 +52,11 @@ func PostHookPostPersistExecutorNames(e []PostHookPostPersistExecutor) []string
 func (f PreHookExecutorFunc) ExecuteRegistrationPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
 	return f(w, r, a)
 }
+
 func (f PostHookPostPersistExecutorFunc) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
 	return f(w, r, a, s)
 }
+
 func (f PostHookPrePersistExecutorFunc) ExecutePostRegistrationPrePersistHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
 	return f(w, r, a, i)
 }
diff --git a/selfservice/flow/settings/error.go b/selfservice/flow/settings/error.go
index b749b5ca4..86b20be7f 100644
--- a/selfservice/flow/settings/error.go
+++ b/selfservice/flow/settings/error.go
@@ -25,7 +25,7 @@ import (
 )
 
 var (
-	ErrHookAbortRequest = errors.New("aborted settings hook execution")
+	ErrHookAbortFlow = errors.New("aborted settings hook execution")
 )
 
 type (
diff --git a/selfservice/flow/settings/handler.go b/selfservice/flow/settings/handler.go
index 0ce280d38..0fd300707 100644
--- a/selfservice/flow/settings/handler.go
+++ b/selfservice/flow/settings/handler.go
@@ -111,6 +111,11 @@ func (h *Handler) NewFlow(w http.ResponseWriter, r *http.Request, i *identity.Id
 	if err != nil {
 		return nil, err
 	}
+
+	if err := h.d.SettingsHookExecutor().PreSettingsHook(w, r, f); err != nil {
+		return nil, err
+	}
+
 	for _, strategy := range h.d.SettingsStrategies(r.Context()) {
 		if err := h.d.ContinuityManager().Abort(r.Context(), w, r, ContinuityKey(strategy.SettingsStrategyID())); err != nil {
 			return nil, err
diff --git a/selfservice/flow/settings/hook.go b/selfservice/flow/settings/hook.go
index 6690891f8..02c3c3388 100644
--- a/selfservice/flow/settings/hook.go
+++ b/selfservice/flow/settings/hook.go
@@ -24,18 +24,27 @@ import (
 )
 
 type (
+	PreHookExecutor interface {
+		ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
+	}
+	PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error
+
 	PostHookPrePersistExecutor interface {
 		ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, a *Flow, s *identity.Identity) error
 	}
 	PostHookPrePersistExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, s *identity.Identity) error
-	PostHookPostPersistExecutor    interface {
+
+	PostHookPostPersistExecutor interface {
 		ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, a *Flow, s *identity.Identity) error
 	}
 	PostHookPostPersistExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, s *identity.Identity) error
-	HooksProvider                   interface {
+
+	HooksProvider interface {
+		PreSettingsHooks(ctx context.Context) []PreHookExecutor
 		PostSettingsPrePersistHooks(ctx context.Context, settingsType string) []PostHookPrePersistExecutor
 		PostSettingsPostPersistHooks(ctx context.Context, settingsType string) []PostHookPostPersistExecutor
 	}
+
 	executorDependencies interface {
 		identity.ManagementProvider
 		identity.ValidationProvider
@@ -57,6 +66,10 @@ type (
 	}
 )
 
+func (f PreHookExecutorFunc) ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
+	return f(w, r, a)
+}
+
 func (f PostHookPrePersistExecutorFunc) ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, a *Flow, s *identity.Identity) error {
 	return f(w, r, a, s)
 }
@@ -164,7 +177,7 @@ func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request,
 		}
 
 		if err := executor.ExecuteSettingsPrePersistHook(w, r, ctxUpdate.Flow, i); err != nil {
-			if errors.Is(err, ErrHookAbortRequest) {
+			if errors.Is(err, ErrHookAbortFlow) {
 				e.d.Logger().WithRequest(r).WithFields(logFields).
 					Debug("A ExecuteSettingsPrePersistHook hook aborted early.")
 				return nil
@@ -229,7 +242,7 @@ func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request,
 
 	for k, executor := range e.d.PostSettingsPostPersistHooks(r.Context(), settingsType) {
 		if err := executor.ExecuteSettingsPostPersistHook(w, r, ctxUpdate.Flow, i); err != nil {
-			if errors.Is(err, ErrHookAbortRequest) {
+			if errors.Is(err, ErrHookAbortFlow) {
 				e.d.Logger().
 					WithRequest(r).
 					WithField("executor", fmt.Sprintf("%T", executor)).
@@ -271,3 +284,13 @@ func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request,
 	x.ContentNegotiationRedirection(w, r, i.CopyWithoutCredentials(), e.d.Writer(), returnTo.String())
 	return nil
 }
+
+func (e *HookExecutor) PreSettingsHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
+	for _, executor := range e.d.PreSettingsHooks(r.Context()) {
+		if err := executor.ExecuteSettingsPreHook(w, r, a); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/selfservice/flow/verification/handler.go b/selfservice/flow/verification/handler.go
index 5a4de45c1..e9efc3a37 100644
--- a/selfservice/flow/verification/handler.go
+++ b/selfservice/flow/verification/handler.go
@@ -49,6 +49,7 @@ type (
 		FlowPersistenceProvider
 		ErrorHandlerProvider
 		StrategyProvider
+		HookExecutorProvider
 	}
 	Handler struct {
 		d handlerDependencies
@@ -80,6 +81,34 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) {
 	admin.GET(RouteSubmitFlow, x.RedirectToPublicRoute(h.d))
 }
 
+type FlowOption func(f *Flow)
+
+func WithFlowReturnTo(returnTo string) FlowOption {
+	return func(f *Flow) {
+		f.ReturnTo = returnTo
+	}
+}
+
+func (h *Handler) NewVerificationFlow(w http.ResponseWriter, r *http.Request, ft flow.Type, opts ...FlowOption) (*Flow, error) {
+	f, err := NewFlow(h.d.Config(), h.d.Config().SelfServiceFlowVerificationRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, h.d.AllVerificationStrategies(), ft)
+	if err != nil {
+		return nil, err
+	}
+	for _, o := range opts {
+		o(f)
+	}
+
+	if err := h.d.VerificationExecutor().PreVerificationHook(w, r, f); err != nil {
+		return nil, err
+	}
+
+	if err := h.d.VerificationFlowPersister().CreateVerificationFlow(r.Context(), f); err != nil {
+		return nil, err
+	}
+
+	return f, nil
+}
+
 // swagger:route GET /self-service/verification/api v0alpha2 initializeSelfServiceVerificationFlowWithoutBrowser
 //
 // Initialize Verification Flow for APIs, Services, Apps, ...
@@ -108,17 +137,12 @@ func (h *Handler) initAPIFlow(w http.ResponseWriter, r *http.Request, _ httprout
 		return
 	}
 
-	req, err := NewFlow(h.d.Config(), h.d.Config().SelfServiceFlowVerificationRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, h.d.VerificationStrategies(r.Context()), flow.TypeAPI)
+	req, err := h.NewVerificationFlow(w, r, flow.TypeAPI)
 	if err != nil {
 		h.d.Writer().WriteError(w, r, err)
 		return
 	}
 
-	if err := h.d.VerificationFlowPersister().CreateVerificationFlow(r.Context(), req); err != nil {
-		h.d.Writer().WriteError(w, r, err)
-		return
-	}
-
 	h.d.Writer().Write(w, r, req)
 }
 
@@ -156,14 +180,9 @@ func (h *Handler) initBrowserFlow(w http.ResponseWriter, r *http.Request, ps htt
 		return
 	}
 
-	req, err := NewFlow(h.d.Config(), h.d.Config().SelfServiceFlowVerificationRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, h.d.VerificationStrategies(r.Context()), flow.TypeBrowser)
+	req, err := h.NewVerificationFlow(w, r, flow.TypeBrowser)
 	if err != nil {
-		h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
-		return
-	}
-
-	if err := h.d.VerificationFlowPersister().CreateVerificationFlow(r.Context(), req); err != nil {
-		h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
+		h.d.Writer().WriteError(w, r, err)
 		return
 	}
 
diff --git a/selfservice/flow/verification/hook.go b/selfservice/flow/verification/hook.go
index b7e90e338..39eaa0395 100644
--- a/selfservice/flow/verification/hook.go
+++ b/selfservice/flow/verification/hook.go
@@ -14,6 +14,11 @@ import (
 )
 
 type (
+	PreHookExecutor interface {
+		ExecuteVerificationPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
+	}
+	PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error
+
 	PostHookExecutor interface {
 		ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error
 	}
@@ -21,6 +26,7 @@ type (
 
 	HooksProvider interface {
 		PostVerificationHooks(ctx context.Context) []PostHookExecutor
+		PreVerificationHooks(ctx context.Context) []PreHookExecutor
 	}
 )
 
@@ -32,6 +38,10 @@ func PostHookVerificationExecutorNames(e []PostHookExecutor) []string {
 	return names
 }
 
+func (f PreHookExecutorFunc) ExecuteVerificationPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
+	return f(w, r, a)
+}
+
 func (f PostHookExecutorFunc) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
 	return f(w, r, a, i)
 }
@@ -63,6 +73,16 @@ func NewHookExecutor(d executorDependencies) *HookExecutor {
 	}
 }
 
+func (e *HookExecutor) PreVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
+	for _, executor := range e.d.PreVerificationHooks(r.Context()) {
+		if err := executor.ExecuteVerificationPreHook(w, r, a); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 func (e *HookExecutor) PostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
 	e.d.Logger().
 		WithRequest(r).
diff --git a/selfservice/hook/error.go b/selfservice/hook/error.go
index fc28d45b8..0f8c6046e 100644
--- a/selfservice/hook/error.go
+++ b/selfservice/hook/error.go
@@ -25,9 +25,14 @@ var (
 
 	_ login.PreHookExecutor  = new(Error)
 	_ login.PostHookExecutor = new(Error)
+	_ login.PreHookExecutor  = new(Error)
 
 	_ settings.PostHookPostPersistExecutor = new(Error)
 	_ settings.PostHookPrePersistExecutor  = new(Error)
+	_ settings.PreHookExecutor             = new(Error)
+
+	_ verification.PreHookExecutor = new(Error)
+	_ recovery.PreHookExecutor     = new(Error)
 )
 
 type Error struct {
@@ -44,12 +49,16 @@ func (e Error) err(path string, abort error) error {
 	return nil
 }
 
+func (e Error) ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, a *settings.Flow) error {
+	return e.err("ExecuteSettingsPreHook", settings.ErrHookAbortFlow)
+}
+
 func (e Error) ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, a *settings.Flow, s *identity.Identity) error {
-	return e.err("ExecuteSettingsPrePersistHook", settings.ErrHookAbortRequest)
+	return e.err("ExecuteSettingsPrePersistHook", settings.ErrHookAbortFlow)
 }
 
 func (e Error) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, a *settings.Flow, s *identity.Identity) error {
-	return e.err("ExecuteSettingsPostPersistHook", settings.ErrHookAbortRequest)
+	return e.err("ExecuteSettingsPostPersistHook", settings.ErrHookAbortFlow)
 }
 
 func (e Error) ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, g node.UiNodeGroup, a *login.Flow, s *session.Session) error {
@@ -72,10 +81,18 @@ func (e Error) ExecutePostRegistrationPrePersistHook(w http.ResponseWriter, r *h
 	return e.err("ExecutePostRegistrationPrePersistHook", registration.ErrHookAbortFlow)
 }
 
+func (e Error) ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *recovery.Flow) error {
+	return e.err("ExecuteRecoveryPreHook", recovery.ErrHookAbortFlow)
+}
+
 func (e Error) ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *recovery.Flow, s *session.Session) error {
 	return e.err("ExecutePostRecoveryHook", recovery.ErrHookAbortFlow)
 }
 
+func (e Error) ExecuteVerificationPreHook(w http.ResponseWriter, r *http.Request, a *verification.Flow) error {
+	return e.err("ExecuteVerificationPreHook", verification.ErrHookAbortFlow)
+}
+
 func (e Error) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *verification.Flow, i *identity.Identity) error {
 	return e.err("ExecutePostVerificationHook", verification.ErrHookAbortFlow)
 }
diff --git a/selfservice/hook/web_hook.go b/selfservice/hook/web_hook.go
index 0d90fe512..df53cb0a6 100644
--- a/selfservice/hook/web_hook.go
+++ b/selfservice/hook/web_hook.go
@@ -96,6 +96,16 @@ func (e *WebHook) ExecuteLoginPostHook(_ http.ResponseWriter, req *http.Request,
 	})
 }
 
+func (e *WebHook) ExecuteVerificationPreHook(_ http.ResponseWriter, req *http.Request, flow *verification.Flow) error {
+	ctx, _ := e.deps.Tracer(req.Context()).Tracer().Start(req.Context(), "selfservice.hook.ExecutePreVerificationHook")
+	return e.execute(ctx, &templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestURL:     x.RequestURL(req).String(),
+	})
+}
+
 func (e *WebHook) ExecutePostVerificationHook(_ http.ResponseWriter, req *http.Request, flow *verification.Flow, id *identity.Identity) error {
 	ctx, _ := e.deps.Tracer(req.Context()).Tracer().Start(req.Context(), "selfservice.hook.ExecutePostVerificationHook")
 	return e.execute(ctx, &templateContext{
@@ -107,6 +117,16 @@ func (e *WebHook) ExecutePostVerificationHook(_ http.ResponseWriter, req *http.R
 	})
 }
 
+func (e *WebHook) ExecuteRecoveryPreHook(_ http.ResponseWriter, req *http.Request, flow *recovery.Flow) error {
+	ctx, _ := e.deps.Tracer(req.Context()).Tracer().Start(req.Context(), "selfservice.hook.ExecutePreRecoveryHook")
+	return e.execute(ctx, &templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestURL:     x.RequestURL(req).String(),
+	})
+}
+
 func (e *WebHook) ExecutePostRecoveryHook(_ http.ResponseWriter, req *http.Request, flow *recovery.Flow, session *session.Session) error {
 	ctx, _ := e.deps.Tracer(req.Context()).Tracer().Start(req.Context(), "selfservice.hook.ExecutePostRecoveryHook")
 	return e.execute(ctx, &templateContext{
@@ -150,6 +170,16 @@ func (e *WebHook) ExecutePostRegistrationPostPersistHook(_ http.ResponseWriter,
 	})
 }
 
+func (e *WebHook) ExecuteSettingsPreHook(_ http.ResponseWriter, req *http.Request, flow *settings.Flow) error {
+	ctx, _ := e.deps.Tracer(req.Context()).Tracer().Start(req.Context(), "selfservice.hook.ExecutePreSettingsHook")
+	return e.execute(ctx, &templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestURL:     x.RequestURL(req).String(),
+	})
+}
+
 func (e *WebHook) ExecuteSettingsPostPersistHook(_ http.ResponseWriter, req *http.Request, flow *settings.Flow, id *identity.Identity) error {
 	ctx, _ := e.deps.Tracer(req.Context()).Tracer().Start(req.Context(), "selfservice.hook.ExecuteSettingsPostPersistHook")
 	return e.execute(ctx, &templateContext{
diff --git a/selfservice/strategy/link/strategy.go b/selfservice/strategy/link/strategy.go
index e9b02cd88..9514e80a8 100644
--- a/selfservice/strategy/link/strategy.go
+++ b/selfservice/strategy/link/strategy.go
@@ -61,6 +61,7 @@ type (
 		verification.FlowPersistenceProvider
 		verification.StrategyProvider
 		verification.HookExecutorProvider
+		verification.HandlerProvider
 
 		RecoveryTokenPersistenceProvider
 		VerificationTokenPersistenceProvider


Test patch:
diff --git a/driver/registry_default_test.go b/driver/registry_default_test.go
index 96ac14a79..356d81b67 100644
--- a/driver/registry_default_test.go
+++ b/driver/registry_default_test.go
@@ -31,6 +31,45 @@ func TestDriverDefault_Hooks(t *testing.T) {
 	ctx := context.Background()
 
 	t.Run("type=verification", func(t *testing.T) {
+		// BEFORE hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []verification.PreHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []verification.PreHookExecutor { return nil },
+			},
+			{
+				uc: "Two web_hooks are configured",
+				prep: func(conf *config.Config) {
+					conf.MustSet(ctx, config.ViperKeySelfServiceVerificationBeforeHooks, []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST"}},
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "bar", "method": "GET"}},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []verification.PreHookExecutor {
+					return []verification.PreHookExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("before/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
+
+				h := reg.PreVerificationHooks(ctx)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+
 		// AFTER hooks
 		for _, tc := range []struct {
 			uc     string
@@ -72,6 +111,45 @@ func TestDriverDefault_Hooks(t *testing.T) {
 	})
 
 	t.Run("type=recovery", func(t *testing.T) {
+		// BEFORE hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []recovery.PreHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []recovery.PreHookExecutor { return nil },
+			},
+			{
+				uc: "Two web_hooks are configured",
+				prep: func(conf *config.Config) {
+					conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryBeforeHooks, []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST"}},
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "bar", "method": "GET"}},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []recovery.PreHookExecutor {
+					return []recovery.PreHookExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("before/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
+
+				h := reg.PreRecoveryHooks(ctx)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+
 		// AFTER hooks
 		for _, tc := range []struct {
 			uc     string
@@ -388,6 +466,45 @@ func TestDriverDefault_Hooks(t *testing.T) {
 	})
 
 	t.Run("type=settings", func(t *testing.T) {
+		// BEFORE hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []settings.PreHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []settings.PreHookExecutor { return nil },
+			},
+			{
+				uc: "Two web_hooks are configured",
+				prep: func(conf *config.Config) {
+					conf.MustSet(ctx, config.ViperKeySelfServiceSettingsBeforeHooks, []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST"}},
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "bar", "method": "GET"}},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []settings.PreHookExecutor {
+					return []settings.PreHookExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("before/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
+
+				h := reg.PreSettingsHooks(ctx)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+
 		// AFTER hooks
 		for _, tc := range []struct {
 			uc     string
diff --git a/identity/identity_recovery_test.go b/identity/identity_recovery_test.go
index f3f2dd71d..b96fac0f9 100644
--- a/identity/identity_recovery_test.go
+++ b/identity/identity_recovery_test.go
@@ -2,9 +2,9 @@ package identity
 
 import (
 	"testing"
+	"time"
 
 	"github.com/gofrs/uuid"
-
 	"github.com/stretchr/testify/assert"
 
 	"github.com/ory/kratos/x"
@@ -19,3 +19,42 @@ func TestNewRecoveryEmailAddress(t *testing.T) {
 	assert.Equal(t, iid, a.IdentityID)
 	assert.Equal(t, uuid.Nil, a.ID)
 }
+
+// TestRecoveryAddress_Hash tests that the hash considers all fields that are
+// written to the database (ignoring some well-known fields like the ID or
+// timestamps).
+func TestRecoveryAddress_Hash(t *testing.T) {
+	cases := []struct {
+		name string
+		a    RecoveryAddress
+	}{
+		{
+			name: "full fields",
+			a: RecoveryAddress{
+				ID:         x.NewUUID(),
+				Value:      "foo@bar.me",
+				Via:        AddressTypeEmail,
+				CreatedAt:  time.Now(),
+				UpdatedAt:  time.Now(),
+				IdentityID: x.NewUUID(),
+				NID:        x.NewUUID(),
+			},
+		}, {
+			name: "empty fields",
+			a:    RecoveryAddress{},
+		}, {
+			name: "constructor",
+			a:    *NewRecoveryEmailAddress("foo@ory.sh", x.NewUUID()),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run("case="+tc.name, func(t *testing.T) {
+			assert.Equal(t,
+				reflectiveHash(tc.a),
+				tc.a.Hash(),
+			)
+		})
+	}
+
+}
diff --git a/identity/identity_verification_test.go b/identity/identity_verification_test.go
index e81901d88..5750c225d 100644
--- a/identity/identity_verification_test.go
+++ b/identity/identity_verification_test.go
@@ -1,7 +1,11 @@
 package identity
 
 import (
+	"fmt"
+	"reflect"
+	"strings"
 	"testing"
+	"time"
 
 	"github.com/gofrs/uuid"
 
@@ -25,3 +29,75 @@ func TestNewVerifiableEmailAddress(t *testing.T) {
 	assert.Equal(t, iid, a.IdentityID)
 	assert.Equal(t, uuid.Nil, a.ID)
 }
+
+var tagsIgnoredForHashing = map[string]struct{}{
+	"id":          {},
+	"created_at":  {},
+	"updated_at":  {},
+	"verified_at": {},
+}
+
+func reflectiveHash(record any) string {
+	var (
+		val    = reflect.ValueOf(record)
+		typ    = reflect.TypeOf(record)
+		values = []string{}
+	)
+	for i := 0; i < val.NumField(); i++ {
+		dbTag, ok := typ.Field(i).Tag.Lookup("db")
+		if !ok {
+			continue
+		}
+		if _, ignore := tagsIgnoredForHashing[dbTag]; ignore {
+			continue
+		}
+		if !val.Field(i).CanInterface() {
+			continue
+		}
+		values = append(values, fmt.Sprintf("%v", val.Field(i).Interface()))
+	}
+	return strings.Join(values, "|")
+}
+
+// TestVerifiableAddress_Hash tests that the hash considers all fields that are
+// written to the database (ignoring some well-known fields like the ID or
+// timestamps).
+func TestVerifiableAddress_Hash(t *testing.T) {
+	now := sqlxx.NullTime(time.Now())
+	cases := []struct {
+		name string
+		a    VerifiableAddress
+	}{
+		{
+			name: "full fields",
+			a: VerifiableAddress{
+				ID:         x.NewUUID(),
+				Value:      "foo@bar.me",
+				Verified:   false,
+				Via:        AddressTypeEmail,
+				Status:     VerifiableAddressStatusPending,
+				VerifiedAt: &now,
+				CreatedAt:  time.Now(),
+				UpdatedAt:  time.Now(),
+				IdentityID: x.NewUUID(),
+				NID:        x.NewUUID(),
+			},
+		}, {
+			name: "empty fields",
+			a:    VerifiableAddress{},
+		}, {
+			name: "constructor",
+			a:    *NewVerifiableEmailAddress("foo@ory.sh", x.NewUUID()),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run("case="+tc.name, func(t *testing.T) {
+			assert.Equal(t,
+				reflectiveHash(tc.a),
+				tc.a.Hash(),
+			)
+		})
+	}
+
+}
diff --git a/internal/testhelpers/selfservice.go b/internal/testhelpers/selfservice.go
index 8a21cb0aa..b55be5557 100644
--- a/internal/testhelpers/selfservice.go
+++ b/internal/testhelpers/selfservice.go
@@ -46,7 +46,7 @@ func TestSelfServicePreHook(
 
 		t.Run("case=err if hooks err", func(t *testing.T) {
 			t.Cleanup(SelfServiceHookConfigReset(t, conf))
-			conf.MustSet(ctx, configKey, []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecuteLoginPreHook": "err","ExecuteRegistrationPreHook": "err"}`)}})
+			conf.MustSet(ctx, configKey, []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecuteLoginPreHook": "err","ExecuteRegistrationPreHook": "err","ExecuteSettingsPreHook": "err","ExecuteVerificationPreHook": "err","ExecuteRecoveryPreHook": "err"}`)}})
 
 			res, body := makeRequestPre(t, newServer(t))
 			assert.EqualValues(t, http.StatusInternalServerError, res.StatusCode, "%s", body)
@@ -55,7 +55,7 @@ func TestSelfServicePreHook(
 
 		t.Run("case=abort if hooks aborts", func(t *testing.T) {
 			t.Cleanup(SelfServiceHookConfigReset(t, conf))
-			conf.MustSet(ctx, configKey, []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecuteLoginPreHook": "abort","ExecuteRegistrationPreHook": "abort"}`)}})
+			conf.MustSet(ctx, configKey, []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecuteLoginPreHook": "abort","ExecuteRegistrationPreHook": "abort","ExecuteSettingsPreHook": "abort","ExecuteVerificationPreHook": "abort","ExecuteRecoveryPreHook": "abort"}`)}})
 
 			res, body := makeRequestPre(t, newServer(t))
 			assert.EqualValues(t, http.StatusOK, res.StatusCode)
@@ -154,7 +154,7 @@ func SelfServiceHookRegistrationErrorHandler(t *testing.T, w http.ResponseWriter
 }
 
 func SelfServiceHookSettingsErrorHandler(t *testing.T, w http.ResponseWriter, r *http.Request, err error) bool {
-	return SelfServiceHookErrorHandler(t, w, r, settings.ErrHookAbortRequest, err)
+	return SelfServiceHookErrorHandler(t, w, r, settings.ErrHookAbortFlow, err)
 }
 
 func SelfServiceHookErrorHandler(t *testing.T, w http.ResponseWriter, r *http.Request, abortErr error, actualErr error) bool {
@@ -182,6 +182,18 @@ func SelfServiceMakeRegistrationPreHookRequest(t *testing.T, ts *httptest.Server
 	return SelfServiceMakeHookRequest(t, ts, "/registration/pre", false, url.Values{})
 }
 
+func SelfServiceMakeSettingsPreHookRequest(t *testing.T, ts *httptest.Server) (*http.Response, string) {
+	return SelfServiceMakeHookRequest(t, ts, "/settings/pre", false, url.Values{})
+}
+
+func SelfServiceMakeRecoveryPreHookRequest(t *testing.T, ts *httptest.Server) (*http.Response, string) {
+	return SelfServiceMakeHookRequest(t, ts, "/recovery/pre", false, url.Values{})
+}
+
+func SelfServiceMakeVerificationPreHookRequest(t *testing.T, ts *httptest.Server) (*http.Response, string) {
+	return SelfServiceMakeHookRequest(t, ts, "/verification/pre", false, url.Values{})
+}
+
 func SelfServiceMakeRegistrationPostHookRequest(t *testing.T, ts *httptest.Server, asAPI bool, query url.Values) (*http.Response, string) {
 	return SelfServiceMakeHookRequest(t, ts, "/registration/post", asAPI, query)
 }
diff --git a/persistence/sql/persister_test.go b/persistence/sql/persister_test.go
index 0474a6b73..ae3eb1f8e 100644
--- a/persistence/sql/persister_test.go
+++ b/persistence/sql/persister_test.go
@@ -285,7 +285,7 @@ func TestPersister_Transaction(t *testing.T) {
 			Traits: ri.Traits(`{}`),
 		}
 		errMessage := "failing because why not"
-		err := p.Transaction(context.Background(), func(ctx context.Context, connection *pop.Connection) error {
+		err := p.Transaction(context.Background(), func(_ context.Context, connection *pop.Connection) error {
 			require.NoError(t, connection.Create(i))
 			return errors.Errorf(errMessage)
 		})
diff --git a/selfservice/flow/recovery/hook_test.go b/selfservice/flow/recovery/hook_test.go
index 32e164009..44bdba6ef 100644
--- a/selfservice/flow/recovery/hook_test.go
+++ b/selfservice/flow/recovery/hook_test.go
@@ -30,6 +30,13 @@ func TestRecoveryExecutor(t *testing.T) {
 
 	newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
 		router := httprouter.New()
+		router.GET("/recovery/pre", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
+			a, err := recovery.NewFlow(conf, time.Minute, x.FakeCSRFToken, r, reg.RecoveryStrategies(context.Background()), ft)
+			require.NoError(t, err)
+			if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PreRecoveryHook(w, r, a)) {
+				_, _ = w.Write([]byte("ok"))
+			}
+		})
 
 		router.GET("/recovery/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
 			a, err := recovery.NewFlow(conf, time.Minute, x.FakeCSRFToken, r, reg.RecoveryStrategies(context.Background()), ft)
@@ -89,4 +96,15 @@ func TestRecoveryExecutor(t *testing.T) {
 			assert.Equal(t, "", body)
 		})
 	})
+
+	for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
+		t.Run("type="+string(kind)+"/method=PreRecoveryHook", testhelpers.TestSelfServicePreHook(
+			config.ViperKeySelfServiceRecoveryBeforeHooks,
+			testhelpers.SelfServiceMakeRecoveryPreHookRequest,
+			func(t *testing.T) *httptest.Server {
+				return newServer(t, nil, kind)
+			},
+			conf,
+		))
+	}
 }
diff --git a/selfservice/flow/registration/hook_test.go b/selfservice/flow/registration/hook_test.go
index 8eb3490df..cbe439df4 100644
--- a/selfservice/flow/registration/hook_test.go
+++ b/selfservice/flow/registration/hook_test.go
@@ -158,23 +158,16 @@ func TestRegistrationExecutor(t *testing.T) {
 				})
 			})
 
-			t.Run("type=browser/method=PreRegistrationHook", testhelpers.TestSelfServicePreHook(
-				config.ViperKeySelfServiceRegistrationBeforeHooks,
-				testhelpers.SelfServiceMakeRegistrationPreHookRequest,
-				func(t *testing.T) *httptest.Server {
-					return newServer(t, nil, flow.TypeBrowser)
-				},
-				conf,
-			))
-
-			t.Run("type=api/method=PreRegistrationHook", testhelpers.TestSelfServicePreHook(
-				config.ViperKeySelfServiceRegistrationBeforeHooks,
-				testhelpers.SelfServiceMakeRegistrationPreHookRequest,
-				func(t *testing.T) *httptest.Server {
-					return newServer(t, nil, flow.TypeAPI)
-				},
-				conf,
-			))
+			for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
+				t.Run("type="+string(kind)+"/method=PreRegistrationHook", testhelpers.TestSelfServicePreHook(
+					config.ViperKeySelfServiceRegistrationBeforeHooks,
+					testhelpers.SelfServiceMakeRegistrationPreHookRequest,
+					func(t *testing.T) *httptest.Server {
+						return newServer(t, nil, kind)
+					},
+					conf,
+				))
+			}
 		})
 	}
 }
diff --git a/selfservice/flow/settings/hook_test.go b/selfservice/flow/settings/hook_test.go
index 4ed6ba035..f5fa15bbf 100644
--- a/selfservice/flow/settings/hook_test.go
+++ b/selfservice/flow/settings/hook_test.go
@@ -46,6 +46,17 @@ func TestSettingsExecutor(t *testing.T) {
 			newServer := func(t *testing.T, ft flow.Type) *httptest.Server {
 				router := httprouter.New()
 				handleErr := testhelpers.SelfServiceHookSettingsErrorHandler
+				router.GET("/settings/pre", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
+					i := testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
+					sess, _ := session.NewActiveSession(ctx, i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
+
+					f, err := settings.NewFlow(conf, time.Minute, r, sess.Identity, ft)
+					require.NoError(t, err)
+					if handleErr(t, w, r, reg.SettingsHookExecutor().PreSettingsHook(w, r, f)) {
+						_, _ = w.Write([]byte("ok"))
+					}
+				})
+
 				router.GET("/settings/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
 					i := testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
 					sess, _ := session.NewActiveSession(ctx, i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
@@ -74,7 +85,6 @@ func TestSettingsExecutor(t *testing.T) {
 			conf.MustSet(ctx, config.ViperKeySelfServiceSettingsURL, uiURL)
 
 			t.Run("method=PostSettingsHook", func(t *testing.T) {
-
 				t.Run("case=pass without hooks", func(t *testing.T) {
 					t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
 
@@ -154,6 +164,17 @@ func TestSettingsExecutor(t *testing.T) {
 					assert.NotEmpty(t, gjson.Get(body, "identity.id"))
 				})
 			})
+
+			for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
+				t.Run("type="+string(kind)+"/method=PreSettingsHook", testhelpers.TestSelfServicePreHook(
+					config.ViperKeySelfServiceSettingsBeforeHooks,
+					testhelpers.SelfServiceMakeSettingsPreHookRequest,
+					func(t *testing.T) *httptest.Server {
+						return newServer(t, kind)
+					},
+					conf,
+				))
+			}
 		})
 	}
 }
diff --git a/selfservice/flow/verification/hook_test.go b/selfservice/flow/verification/hook_test.go
index 75b9bafc3..d84bfb14d 100644
--- a/selfservice/flow/verification/hook_test.go
+++ b/selfservice/flow/verification/hook_test.go
@@ -28,6 +28,13 @@ func TestVerificationExecutor(t *testing.T) {
 
 	newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
 		router := httprouter.New()
+		router.GET("/verification/pre", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
+			a, err := verification.NewFlow(conf, time.Minute, x.FakeCSRFToken, r, reg.VerificationStrategies(context.Background()), ft)
+			require.NoError(t, err)
+			if testhelpers.SelfServiceHookErrorHandler(t, w, r, verification.ErrHookAbortFlow, reg.VerificationExecutor().PreVerificationHook(w, r, a)) {
+				_, _ = w.Write([]byte("ok"))
+			}
+		})
 
 		router.GET("/verification/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
 			a, err := verification.NewFlow(conf, time.Minute, x.FakeCSRFToken, r, reg.VerificationStrategies(context.Background()), ft)
@@ -79,5 +86,17 @@ func TestVerificationExecutor(t *testing.T) {
 			assert.EqualValues(t, http.StatusOK, res.StatusCode)
 			assert.Equal(t, "", body)
 		})
+
+		for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
+			t.Run("type="+string(kind)+"/method=PreVerificationHook", testhelpers.TestSelfServicePreHook(
+				config.ViperKeySelfServiceVerificationBeforeHooks,
+				testhelpers.SelfServiceMakeVerificationPreHookRequest,
+				func(t *testing.T) *httptest.Server {
+					i := testhelpers.SelfServiceHookFakeIdentity(t)
+					return newServer(t, i, kind)
+				},
+				conf,
+			))
+		}
 	})
 }
diff --git a/selfservice/strategy/link/test/persistence.go b/selfservice/strategy/link/test/persistence.go
index b6ed26f00..f8648bf82 100644
--- a/selfservice/strategy/link/test/persistence.go
+++ b/selfservice/strategy/link/test/persistence.go
@@ -93,8 +93,31 @@ func TestPersister(ctx context.Context, conf *config.Config, p interface {
 				assert.NotEqual(t, expected.Token, actual.Token)
 				assert.EqualValues(t, expected.FlowID, actual.FlowID)
 
-				_, err = p.UseRecoveryToken(ctx, f.ID, expected.Token)
-				require.Error(t, err)
+				t.Run("double spend", func(t *testing.T) {
+					_, err = p.UseRecoveryToken(ctx, f.ID, expected.Token)
+					require.Error(t, err)
+				})
+			})
+
+			t.Run("case=update to identity should not invalidate token", func(t *testing.T) {
+				expected, f := newRecoveryToken(t, "some-user@ory.sh")
+
+				require.NoError(t, p.CreateRecoveryToken(ctx, expected))
+				id, err := p.GetIdentity(ctx, expected.IdentityID)
+				require.NoError(t, err)
+				require.NoError(t, p.UpdateIdentity(ctx, id))
+
+				actual, err := p.UseRecoveryToken(ctx, f.ID, expected.Token)
+				require.NoError(t, err)
+				assert.Equal(t, nid, actual.NID)
+				assert.Equal(t, expected.IdentityID, actual.IdentityID)
+				assert.NotEqual(t, expected.Token, actual.Token)
+				assert.EqualValues(t, expected.FlowID, actual.FlowID)
+
+				t.Run("double spend", func(t *testing.T) {
+					_, err = p.UseRecoveryToken(ctx, f.ID, expected.Token)
+					require.Error(t, err)
+				})
 			})
 
 		})