Repository: ory/kratos
Pull request #1289 (2021-04-28 13:19:20)

feat: web hooks support (recovery)
## Related issue

https://github.com/ory/kratos/issues/271

## Related PRs

Since unfortunately the original repository has been deleted, the https://github.com/ory/kratos/pull/1240 became disconnected. This PR is meant as a replacement. It contains all the changes done so far in the aforesaid PR.

## Proposed changes

What follows is copy & paste from https://github.com/ory/kratos/pull/1240 to give context.

As discussed in the linked ticket this PR describes the idea of a possible implementation for web hooks. The support is currently added to `verification` and `registration` flows only and should be the basis for further discussion. As of now, the implementation is very simple - no retries, no asynchrony. The actual implementation of how the configuration is parsed and applied is also very simple and should be changed to be more concise. 

The configuration for the web-hooks goes into the `hooks` property and has the following structure:

```.yaml
hooks:
  - hook: web-hook
    config:
      url: https://test.kratos.ory.sh/after_verification_hook
      method: POST # GET, DELETE, etc
      body: /link/to/my/jsonnet/file
      auth:
        type: <some-type>
        config:
          <type-specific-config>
```

The `flow` and `session` objects are currently bound into the jsonnet template. E.g. to create a body looking like `{ user_id: <some-id> }` to be send to the web hook end point, the jsonnet template would look like this: `function(flow, session) { user_id: session.identity.id }`

For `auth` following types are implemented:
* api-key
* basic-auth

For `api-key` the config looks as follows:

```.yaml
name: Some-Name
value: The-Value-of-My-Key
in: header # alternatively cookie
```

For `basic-auth` the config looks as follows:
```.yaml
user: My-User
password: My-Pass-Value
```

The `after` property of `registration` and `verification` has been extended to contain a `global` property, which consists only out of a `hooks` property, which can contain as many web-hooks in the above said format as desired.

The `hooks` property of `oidc` and `password` properties of the aforesaid flows were extended to contain the web-hook item in addition to already defined hook types.


## 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).

## Further comments

As the support for web-hooks touches many flows, I rather would like to start small and discuss the desired changes in the implementation and usage first, before adopting the support to all other flows.

I used the word `auth` for the configuration of "security" settings of a web-hook by intention. What has been done so far is IMHO just a small part of what might be required. I can see the need for custom certificate trust stores to verify the servers certificate, as well as the need to support mTLS. Other mechanisms, or combinations might be required as well.



dadrus: The https://github.com/ory/kratos/pull/1289/commits/876c8ea36d308e5bbef1925585f2d8293d3a0381 commit just adds a comment to see, whether it can salvage the #1240 PR. Unfortunately no.
aeneasr: I will take a look tomorrow!
dadrus: @aeneasr: The web-hook support has been added to the "after" property of all flows, but error and logout. I personally don't see any added value by adding the support for these two. What is your opinion?

Support for "before" properties has not been added yet. To be honest I personally would expect that the results of such hooks would somehow modify/affect the flow. This is however not yet discussed/designed. The only effect, which could be achieved with the given implementation, would be to cancel the further execution of the flow if the execution of a particular web-hook fails. If this is this desired behavior, I can add this support as well. However, even there is some code available, as well as some tests, the current schema does not define "before" hooks at all. So as of today, these cannot be defined in the kratos.yaml anyway, as kratos refuse to start due to config validation errors. So how should we proceed here?
dadrus: @aeneasr: There is one thing, which I personally don't like at all and this is the current "hook interface". I put it in braces, because, there is no defined (FMPOV) and this leads to a lot of code duplication. If you, for example, take a look at the `web_hook.go` file, you'll see, the `WebHook` struct has to implement different `PostHookExecutor` interfaces, which only differ in one argument - the passed in `Flow` object. This becomes more prominent in the implementation of the `hook.go` file of each strategy. It is almost all the same and the difference is again almost related to a specifically typed `Flow` argument. 

I think by introducing an interface for a `Flow` and by defining the hooks wiring and infrastructure types in a form of 

```.go
type (
	PreHookExecutor interface {
		ExecutePreFlowHook(w http.ResponseWriter, r *http.Request, a Flow) error
	}
	PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a Flow) error

	PostHookPostPersistExecutor interface {
		ExecutePostFlowPostPersistHook(w http.ResponseWriter, r *http.Request, a Flow, s *session.Session) error
	}
	PostHookPostPersistExecutorFunc func(w http.ResponseWriter, r *http.Request, a Flow, s *session.Session) error

	PostHookPrePersistExecutor interface {
		ExecutePostFlowPrePersistHook(w http.ResponseWriter, r *http.Request, a Flow, i *identity.Identity) error
	}
	PostHookPrePersistExecutorFunc func(w http.ResponseWriter, r *http.Request, a Flow, i *identity.Identity) error

	HooksProvider interface {
		PreFlowHooks(ctx context.Context) []PreHookExecutor
		PostFlowPrePersistHooks(ctx context.Context, credentialsType identity.CredentialsType) []PostHookPrePersistExecutor
		PostFlowPostPersistHooks(ctx context.Context, credentialsType identity.CredentialsType) []PostHookPostPersistExecutor
	}
)
```

The entire hooks execution logic can be in one place and the WebHook implementation would only have to implement just the three methods defined in the `HooksProvider` from the above snippet.

I don't see this as part of this PR, but it would be great if we could refactor that to remove the duplications and make the code easier to understand.
aeneasr: I don't think https://github.com/ory/kratos/pull/1289#issuecomment-829133224 should be addressed in this PR. There are good reasons for keeping this logic separated as it allows us to, for example, get full control over what hooks are available when where for which flows!
aeneasr: It's a bit of duplication but it's not that bad IMO and it allows flexibility down the road :)
aeneasr: @dadrus can you please enable write access for maintainers?

```
% git push innoq HEAD:feature/web-hooks-support
ERROR: Permission to innoq/kratos.git denied to aeneasr.
fatal: Konnte nicht vom Remote-Repository lesen.
```

https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork
dadrus: > @dadrus can you please enable write access for maintainers?

Unfortunately I don't see the option described in https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork
dadrus: @aeneasr : Try pushing again. GitHub offers a possibility to enable access to the entire repository for contributors.
dadrus: Or should I just merge your PR https://github.com/innoq/kratos/pull/1 ?
harnash: > @aeneasr: The web-hook support has been added to the "after" property of all, but error and logout flows. I personally don't see any added value by adding the support for these two. What is your opinion?
> 
> Support for "before" properties has not been added yet. To be honest I personally would expect that the results of such hooks would somehow modify/affect the flow. This is however not yet discussed/designed. The only effect, which could be achieved with the given implementation, would be to cancel the further execution of the flow if the execution of a particular web-hook fails. If this is this desired behavior, I can add this support as well. However, even there is some code available, as well as some tests, the current schema does not define "before" hooks at all. So as of today, these cannot be defined in the kratos.yaml anyway, as kratos refuse to start due to config validation errors. So how should we proceed here? IMHO this is relevant for #882

If that helps. We do have  a scenario of `before` hook as you described. We run various checks on external services that basically do some checks and optionally prohibit user from logging in or dropping session altogether. It is nice to have but that is just our opinion :-)
dadrus: Ok. I'll add support for `before` hooks in `registration` and `login` as it will at least partially solve #882 

Later on one could add support for an additional jssonnet template, which could modify flow and/or session object based on particular application requirements and on the result of a web-hook call (would be other PR).
dadrus: @aeneasr: How would you like to proceed related to https://github.com/ory/kratos/pull/1289#issuecomment-829274471 and https://github.com/ory/kratos/pull/1289#issuecomment-829275068
harnash: > @harnash: Take a look, does it cover your use cases?

Thank you @dadrus! In terms of hooks I think it covers our use cases.
aeneasr: ````
feat: web hooks for self-service flows

This feature adds the ability to define web-hooks using a mixture of configuration and JsonNet. This allows integration with services like Mailchimp, Stripe, CRMs, and all other APIs that support REST requests. Additionally to these new changes, it is now possible to define hooks for verification and recovery as well!

The configuration for the web-hooks goes into the `hooks` property and has the following structure:

```.yaml
hooks:
  - hook: web_hook
    config:
      url: https://test.kratos.ory.sh/after_verification_hook
      method: POST # GET, DELETE, etc
      body: /link/to/my/jsonnet/file
      auth:
        type: <some-type>
        config:
          <type-specific-config>
```

The `flow` and `session` objects are currently bound into the jsonnet template. E.g. to create a body looking like `{ user_id: <some-id> }` to be send to the web hook end point, the jsonnet template would look like this: `function(flow, session) { user_id: session.identity.id }`

For `auth` following types are implemented:
* api-key
* basic-auth

For `api-key` the config looks as follows:

```.yaml
name: Some-Name
value: The-Value-of-My-Key
in: header # alternatively cookie
```

For `basic-auth` the config looks as follows:
```.yaml
user: My-User
password: My-Pass-Value
```

BREAKING CHANGE: As part of this change the configuration layout for hooks has changed. These are now to be defined under the `hook` key:

```patch
  selfservice:
    flows:
       <registration|login|settings|...>:
         after:
+          hooks:
+            - hook: some_hook
+              # ...
           <password|oidc|profile|...>:
-            - hook: some_hook
-              # ...
+            hooks:
+              - hook: some_hook
+                # ...
```

````
dadrus: @aeneasr: I now explicitly added you to the contributors list in the fork. You should now be able to do whatever you want in the fork ;)
dadrus: @aeneasr:
> ... We just need a few more test cases for the new hook handlers and an integration test for the web hook :)

Yep. I have started adding such tests last week (Not pushed yet as you can see).
dadrus: @aeneasr: Regarding https://github.com/ory/kratos/pull/1289#issuecomment-831130724
I assume, this is your preparation for the release notes. I would rather reference docs/docs/self-service/hooks.mdx as 
> The `flow` and `session` objects are currently bound into the jsonnet template. E.g. to create a body looking like `{ user_id: <some-id> }` to be send to the web hook end point, the jsonnet template would look like this: `function(flow, session) { user_id: session.identity.id }`

is not correct (at least the example).

dadrus: I'll add tests, which test the different web hook methods tomorrow. Thank you for waiting with the new release until I'm ready :)
aeneasr: > I don't know, how it was in the past, but already with v0.5 of kratos, hooks had to be configured in a hooks property of a method.

Oh, thank you for pointing that out! Looks like I remembered wrong 😅
dadrus: Status update:
* Implementation is almost complete (see below)
* Documentation is almost complete (see below)
* Testing of the new functionality is pretty extensive.

Regarding the "almost": 
As already written in https://github.com/ory/kratos/pull/1289#issuecomment-829133224, I see some improvement related to how hooks in general should be designed in sense of the interface. Related to this, 
* some hooks enable access only the flow object (in addition to the request properties) in the jsonnet template. These are the "pre" method hooks. Since the user is not yet registered, or logged in, this is fine and expected.
* other give access to the session and identity objects in addition,
* yet other give only access to the identity object in addition to the flow and the request properties. So session object is not available. Affected flows are settings and verification flows. Since the corresponding two required interface were added by me, I assume I had to do something in addition, e.g. to fetch the session data from the db.

What I actually want to say is that the usage of the web hooks is not intuitive because of this. Btw. this specifics is not yet reflected in the documentation. My own expectation is, that the `before` hooks make only the `flow` object as well as the request properties accessible in the jsonnnet template. The `after` hooks should make everything accessible and it should not be dependent on a particular method. To resolve this, I would like to remove the `session` object from the `ctx` object passed to the template. As there is no admin API, or other way to make use of it, I personally don't see any added value by binding it to the template. So the `before` hooks would make the `flow`, `request_method`, `request_uri` and `request_headers` available and the `after` hooks would make the `identity` object available as well. This is easy to remember and less error prone compared to the current implementation. The changes are trivial and affect only the internal implementation of the web hook.

What do you think?
aeneasr: > What I actually want to say is that the usage of the web hooks is not intuitive because of this. Respectively these specifics are not yet reflected in the documentation. My own expectation is, that the before hooks make only the flow object as well as the request properties accessible in the jsonnnet template. The after hooks should make everything accessible and it should not be dependent on a particular method. To resolve this, I would like to remove the session object from the ctx object passed to the template. As there is no admin API, or other way to make use of it (session data), I personally don't see any added value by binding it to the template. So the before hooks would make the flow, request_method, request_uri and request_headers available and the after hooks would make the identity object available as well. This is easy to remember and less error prone compared to the current implementation. The changes are trivial and affect only the internal implementation of the web hook.

I would say that sounds like a plan!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b810381cd..7aa5ec55f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,107 +4,110 @@
 <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
 **Table of Contents**
 
-- [Unreleased (2021-04-29)](#unreleased-2021-04-29)
-- [0.6.0-alpha.1.pre.2 (2021-04-29)](#060-alpha1pre2-2021-04-29)
+- [Unreleased (2021-05-04)](#unreleased-2021-05-04)
     - [Bug Fixes](#bug-fixes)
     - [Features](#features)
+    - [Tests](#tests)
+- [0.6.0-alpha.1.pre.2 (2021-04-29)](#060-alpha1pre2-2021-04-29)
+    - [Bug Fixes](#bug-fixes-1)
+    - [Features](#features-1)
 - [0.6.0-alpha.1.pre.1 (2021-04-29)](#060-alpha1pre1-2021-04-29)
     - [Documentation](#documentation)
 - [0.6.0-alpha.1.pre.0 (2021-04-29)](#060-alpha1pre0-2021-04-29)
-    - [Bug Fixes](#bug-fixes-1)
+    - [Bug Fixes](#bug-fixes-2)
     - [Code Refactoring](#code-refactoring)
     - [Documentation](#documentation-1)
-    - [Features](#features-1)
-    - [Tests](#tests)
+    - [Features](#features-2)
+    - [Tests](#tests-1)
     - [Unclassified](#unclassified)
     - [BREAKING CHANGES](#breaking-changes)
   - [0.5.5-alpha.1 (2020-12-09)](#055-alpha1-2020-12-09)
-    - [Bug Fixes](#bug-fixes-2)
+    - [Bug Fixes](#bug-fixes-3)
     - [Documentation](#documentation-2)
-    - [Features](#features-2)
-    - [Tests](#tests-1)
+    - [Features](#features-3)
+    - [Tests](#tests-2)
     - [Unclassified](#unclassified-1)
   - [0.5.4-alpha.1 (2020-11-11)](#054-alpha1-2020-11-11)
-    - [Bug Fixes](#bug-fixes-3)
+    - [Bug Fixes](#bug-fixes-4)
     - [Code Refactoring](#code-refactoring-1)
     - [Documentation](#documentation-3)
-    - [Features](#features-3)
+    - [Features](#features-4)
   - [0.5.3-alpha.1 (2020-10-27)](#053-alpha1-2020-10-27)
-    - [Bug Fixes](#bug-fixes-4)
+    - [Bug Fixes](#bug-fixes-5)
     - [Documentation](#documentation-4)
-    - [Features](#features-4)
-    - [Tests](#tests-2)
+    - [Features](#features-5)
+    - [Tests](#tests-3)
   - [0.5.2-alpha.1 (2020-10-22)](#052-alpha1-2020-10-22)
-    - [Bug Fixes](#bug-fixes-5)
+    - [Bug Fixes](#bug-fixes-6)
     - [Documentation](#documentation-5)
-    - [Tests](#tests-3)
+    - [Tests](#tests-4)
   - [0.5.1-alpha.1 (2020-10-20)](#051-alpha1-2020-10-20)
-    - [Bug Fixes](#bug-fixes-6)
+    - [Bug Fixes](#bug-fixes-7)
     - [Documentation](#documentation-6)
-    - [Features](#features-5)
-    - [Tests](#tests-4)
+    - [Features](#features-6)
+    - [Tests](#tests-5)
     - [Unclassified](#unclassified-2)
 - [0.5.0-alpha.1 (2020-10-15)](#050-alpha1-2020-10-15)
-    - [Bug Fixes](#bug-fixes-7)
+    - [Bug Fixes](#bug-fixes-8)
     - [Code Refactoring](#code-refactoring-2)
     - [Documentation](#documentation-7)
-    - [Features](#features-6)
-    - [Tests](#tests-5)
+    - [Features](#features-7)
+    - [Tests](#tests-6)
     - [Unclassified](#unclassified-3)
     - [BREAKING CHANGES](#breaking-changes-1)
   - [0.4.6-alpha.1 (2020-07-13)](#046-alpha1-2020-07-13)
-    - [Bug Fixes](#bug-fixes-8)
-  - [0.4.5-alpha.1 (2020-07-13)](#045-alpha1-2020-07-13)
     - [Bug Fixes](#bug-fixes-9)
-  - [0.4.4-alpha.1 (2020-07-10)](#044-alpha1-2020-07-10)
+  - [0.4.5-alpha.1 (2020-07-13)](#045-alpha1-2020-07-13)
     - [Bug Fixes](#bug-fixes-10)
+  - [0.4.4-alpha.1 (2020-07-10)](#044-alpha1-2020-07-10)
+    - [Bug Fixes](#bug-fixes-11)
     - [Documentation](#documentation-8)
   - [0.4.3-alpha.1 (2020-07-08)](#043-alpha1-2020-07-08)
-    - [Bug Fixes](#bug-fixes-11)
-  - [0.4.2-alpha.1 (2020-07-08)](#042-alpha1-2020-07-08)
     - [Bug Fixes](#bug-fixes-12)
-- [0.4.0-alpha.1 (2020-07-08)](#040-alpha1-2020-07-08)
+  - [0.4.2-alpha.1 (2020-07-08)](#042-alpha1-2020-07-08)
     - [Bug Fixes](#bug-fixes-13)
+- [0.4.0-alpha.1 (2020-07-08)](#040-alpha1-2020-07-08)
+    - [Bug Fixes](#bug-fixes-14)
     - [Code Refactoring](#code-refactoring-3)
     - [Documentation](#documentation-9)
-    - [Features](#features-7)
+    - [Features](#features-8)
     - [Unclassified](#unclassified-4)
     - [BREAKING CHANGES](#breaking-changes-2)
 - [0.3.0-alpha.1 (2020-05-15)](#030-alpha1-2020-05-15)
-    - [Bug Fixes](#bug-fixes-14)
+    - [Bug Fixes](#bug-fixes-15)
     - [Code Refactoring](#code-refactoring-4)
     - [Documentation](#documentation-10)
-    - [Features](#features-8)
+    - [Features](#features-9)
     - [Unclassified](#unclassified-5)
     - [BREAKING CHANGES](#breaking-changes-3)
   - [0.2.1-alpha.1 (2020-05-05)](#021-alpha1-2020-05-05)
     - [Documentation](#documentation-11)
 - [0.2.0-alpha.2 (2020-05-04)](#020-alpha2-2020-05-04)
-    - [Bug Fixes](#bug-fixes-15)
+    - [Bug Fixes](#bug-fixes-16)
     - [Code Refactoring](#code-refactoring-5)
     - [Documentation](#documentation-12)
-    - [Features](#features-9)
+    - [Features](#features-10)
     - [Unclassified](#unclassified-6)
     - [BREAKING CHANGES](#breaking-changes-4)
   - [0.1.1-alpha.1 (2020-02-18)](#011-alpha1-2020-02-18)
-    - [Bug Fixes](#bug-fixes-16)
+    - [Bug Fixes](#bug-fixes-17)
     - [Code Refactoring](#code-refactoring-6)
     - [Documentation](#documentation-13)
 - [0.1.0-alpha.6 (2020-02-16)](#010-alpha6-2020-02-16)
-    - [Bug Fixes](#bug-fixes-17)
+    - [Bug Fixes](#bug-fixes-18)
     - [Code Refactoring](#code-refactoring-7)
     - [Documentation](#documentation-14)
-    - [Features](#features-10)
+    - [Features](#features-11)
 - [0.1.0-alpha.5 (2020-02-06)](#010-alpha5-2020-02-06)
     - [Documentation](#documentation-15)
-    - [Features](#features-11)
+    - [Features](#features-12)
 - [0.1.0-alpha.4 (2020-02-06)](#010-alpha4-2020-02-06)
     - [Documentation](#documentation-16)
 - [0.1.0-alpha.3 (2020-02-06)](#010-alpha3-2020-02-06)
 - [0.1.0-alpha.2 (2020-02-03)](#010-alpha2-2020-02-03)
-    - [Bug Fixes](#bug-fixes-18)
+    - [Bug Fixes](#bug-fixes-19)
     - [Documentation](#documentation-17)
-    - [Features](#features-12)
+    - [Features](#features-13)
     - [Unclassified](#unclassified-7)
 - [0.1.0-alpha.1 (2020-01-31)](#010-alpha1-2020-01-31)
     - [Documentation](#documentation-18)
@@ -142,9 +145,32 @@
 
 <!-- END doctoc generated TOC please keep comment here to allow auto update -->
 
-# Unreleased (2021-04-29)
+# [Unreleased](https://github.com/ory/kratos/compare/v0.6.0-alpha.1.pre.2...59581e3fede0fd43028a5f064c350c3cc833b5b0) (2021-05-04)
+
+
+### Bug Fixes
+
+* Do not return system errors for missing identifiers ([1fcc855](https://github.com/ory/kratos/commit/1fcc8557bfee0f7ba562a635670b61dc9acb3530)), closes [#1286](https://github.com/ory/kratos/issues/1286)
+* Rehydrate settings form on successful submission ([3457e1a](https://github.com/ory/kratos/commit/3457e1a46f48ed79eabff76f8af08b82f12ecc89)), closes [#1305](https://github.com/ory/kratos/issues/1305)
+* Remove continuity cookies on errors ([85eea67](https://github.com/ory/kratos/commit/85eea6748be6ae8cdfc10cabaa6b677e4efd63eb))
+* Remove trailing double-dot from error ([59581e3](https://github.com/ory/kratos/commit/59581e3fede0fd43028a5f064c350c3cc833b5b0))
+* Resolve double hook invokation for registration ([032322c](https://github.com/ory/kratos/commit/032322c66fb6925d8f1473746cb4bfd800d60590))
+* Resolve regressions in cookies and payloads ([9e34bf2](https://github.com/ory/kratos/commit/9e34bf2f6a2f3b007069a5415643c448798207a6))
+* Resolve ui issues with nested form objects ([8e744b9](https://github.com/ory/kratos/commit/8e744b931954283cf5f5cbf3ebaca3fa94e035ed))
+* Run correct error handler for registration hooks ([0d80447](https://github.com/ory/kratos/commit/0d80447102d5092e310ca728012f083147c0c5c9))
+* Support form and json formats in decoder ([d420fe6](https://github.com/ory/kratos/commit/d420fe6e8a491b20063d4bfeaa0a841058087d32))
+* Update openapi definitions for signup ([eb0b69d](https://github.com/ory/kratos/commit/eb0b69d50ce834b170186a39bbc9cda4d3366c36))
+
+
+### Features
+
+* Email input validation ([#1287](https://github.com/ory/kratos/issues/1287)) ([cd56b73](https://github.com/ory/kratos/commit/cd56b73df363dd37485f07d31fef11fd4d9f40a6)), closes [#1285](https://github.com/ory/kratos/issues/1285)
+
+
+### Tests
+
+* Resolve regression ([e2b0ad3](https://github.com/ory/kratos/commit/e2b0ad3c1845da80f078b11b327b9a0376cbb7c5))
 
-No significant changes have been made for this release.
 
 
 # [0.6.0-alpha.1.pre.2](https://github.com/ory/kratos/compare/v0.6.0-alpha.1.pre.1...v0.6.0-alpha.1.pre.2) (2021-04-29)
diff --git a/cmd/jsonnet/lint.go b/cmd/jsonnet/lint.go
index 8cfd6d75e..166f0f56d 100644
--- a/cmd/jsonnet/lint.go
+++ b/cmd/jsonnet/lint.go
@@ -5,6 +5,7 @@ import (
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/google/go-jsonnet"
 	"github.com/google/go-jsonnet/linter"
@@ -21,6 +22,8 @@ var jsonnetLintCmd = &cobra.Command{
 ` + GlobHelp,
 	Args: cobra.MinimumNArgs(1),
 	Run: func(cmd *cobra.Command, args []string) {
+		vm := jsonnet.MakeVM()
+
 		for _, pattern := range args {
 			files, err := filepath.Glob(pattern)
 			cmdx.Must(err, `Glob path "%s" is not valid: %s`, pattern, err)
@@ -29,12 +32,10 @@ var jsonnetLintCmd = &cobra.Command{
 				content, err := ioutil.ReadFile(file)
 				cmdx.Must(err, `Unable to read file "%s" because: %s`, file, err)
 
-				node, err := jsonnet.SnippetToAST(file, string(content))
-				cmdx.Must(err, `Unable to parse JSONNet source "%s" because: %s`, file, err)
+				var outBuilder strings.Builder
+				errorsFound := linter.LintSnippet(vm, &outBuilder, file, string(content))
 
-				ew := &linter.ErrorWriter{Writer: os.Stderr}
-				linter.Lint(node, ew)
-				if ew.ErrorsFound {
+				if errorsFound {
 					_, _ = fmt.Fprintf(os.Stderr, "Linter found issues.")
 					os.Exit(1)
 				}
diff --git a/docs/docs/guides/integration-with-other-systems-using-web-hooks.md b/docs/docs/guides/integration-with-other-systems-using-web-hooks.md
new file mode 100644
index 000000000..829e6e5eb
--- /dev/null
+++ b/docs/docs/guides/integration-with-other-systems-using-web-hooks.md
@@ -0,0 +1,47 @@
+---
+id: integration-with-other-systems-using-web-hooks
+title: Integration using Web-Hooks
+---
+
+Ory Kratos supports integration with other systems (e.g. ERP, Marketing, etc)
+using Web-Hooks.
+
+### Mailchimp
+
+To send marketing email using [Mailchimp](https://mailchimp.com) upon
+registration, add the following to your Ory Kratos config:
+
+```yaml title="path/to/my/kratos.config.yml"
+selfservice:
+  flows:
+    registration:
+      after:
+        password:
+          hooks:
+            - hook: web_hook
+              config:
+                url: https://mandrillapp.com/api/1.0/messages/send
+                method: POST
+                body: /path/to/my/mandrillapp.jsonnet
+```
+
+And create a JsonNet file. Please be aware that Mailchimps' authentication
+mechanism currently requires to save the Mailchimp key in the JsonNet. For other
+systems you would be using the `web_hook`'s `auth` config.
+
+```jsonnet title="/path/to/my/mandrillapp.jsonnet"
+function(ctx) {
+  key: "<Your-Api-Key>",
+  message: {
+    from_email: "hello@example.com",
+    subject: "Hello from Ory Kratos",
+    text: "Welcome to Ory Kratos",
+    to: [
+      {
+        email: ctx.identity.verifiable_addresses[0].value,
+        type: "to"
+      }
+    ]
+  }
+}
+```
diff --git a/docs/docs/reference/configuration.md b/docs/docs/reference/configuration.md
index 070579478..0e972228a 100644
--- a/docs/docs/reference/configuration.md
+++ b/docs/docs/reference/configuration.md
@@ -163,9 +163,36 @@ selfservice:
       #
       lifespan: 1h
 
+      ## before ##
+      #
+      before:
+        ## hooks ##
+        #
+        # Set this value using environment variables on
+        # - Linux/macOS:
+        #    $ export SELFSERVICE_FLOWS_REGISTRATION_BEFORE_HOOKS=<value>
+        # - Windows Command Line (CMD):
+        #    > set SELFSERVICE_FLOWS_REGISTRATION_BEFORE_HOOKS=<value>
+        #
+        hooks:
+          - hook: web_hook
+            config: # web_hook specific configuration
+
       ## after ##
       #
       after:
+        ## hooks ##
+        #
+        # Set this value using environment variables on
+        # - Linux/macOS:
+        #    $ export SELFSERVICE_FLOWS_REGISTRATION_AFTER_HOOKS=<value>
+        # - Windows Command Line (CMD):
+        #    > set SELFSERVICE_FLOWS_REGISTRATION_AFTER_HOOKS=<value>
+        #
+        hooks:
+          - hook: web_hook
+            config: # web_hook specific configuration
+
         ## password ##
         #
         password:
@@ -179,6 +206,8 @@ selfservice:
           #
           hooks:
             - hook: session
+            - hook: web_hook
+              config: # web_hook specific configuration
 
           ## Redirect browsers to set URL per default ##
           #
@@ -209,6 +238,8 @@ selfservice:
           #
           hooks:
             - hook: session
+            - hook: web_hook
+              config: # web_hook specific configuration
 
           ## Redirect browsers to set URL per default ##
           #
@@ -279,9 +310,36 @@ selfservice:
       #
       lifespan: 1h
 
+      ## before ##
+      #
+      before:
+        ## hooks ##
+        #
+        # Set this value using environment variables on
+        # - Linux/macOS:
+        #    $ export SELFSERVICE_FLOWS_LOGIN_BEFORE_HOOKS=<value>
+        # - Windows Command Line (CMD):
+        #    > set SELFSERVICE_FLOWS_LOGIN_BEFORE_HOOKS=<value>
+        #
+        hooks:
+          - hook: web_hook
+            config: # web_hook specific configuration
+
       ## after ##
       #
       after:
+        ## hooks ##
+        #
+        # Set this value using environment variables on
+        # - Linux/macOS:
+        #    $ export SELFSERVICE_FLOWS_LOGIN_AFTER_HOOKS=<value>
+        # - Windows Command Line (CMD):
+        #    > set SELFSERVICE_FLOWS_LOGIN_AFTER_HOOKS=<value>
+        #
+        hooks:
+          - hook: web_hook
+            config: # web_hook specific configuration
+
         ## password ##
         #
         password:
@@ -295,6 +353,8 @@ selfservice:
           #
           hooks:
             - hook: revoke_active_sessions
+            - hook: web_hook
+              config: # web_hook specific configuration
 
           ## Redirect browsers to set URL per default ##
           #
@@ -325,6 +385,8 @@ selfservice:
           #
           hooks:
             - hook: revoke_active_sessions
+            - hook: web_hook
+              config: # web_hook specific configuration
 
           ## Redirect browsers to set URL per default ##
           #
@@ -414,6 +476,18 @@ selfservice:
         #
         default_browser_return_url: https://my-app.com/dashboard
 
+        ## hooks ##
+        #
+        # Set this value using environment variables on
+        # - Linux/macOS:
+        #    $ export SELFSERVICE_FLOWS_VERIFICATION_AFTER_HOOKS=<value>
+        # - Windows Command Line (CMD):
+        #    > set SELFSERVICE_FLOWS_VERIFICATION_AFTER_HOOKS=<value>
+        #
+        hooks:
+          - hook: web_hook
+            config: # web_hook specific configuration
+
       ## Self-Service Verification Request Lifespan ##
       #
       # Sets how long the verification request (for the UI interaction) is valid.
@@ -486,6 +560,18 @@ selfservice:
         #
         default_browser_return_url: https://my-app.com/dashboard
 
+        ## hooks ##
+        #
+        # Set this value using environment variables on
+        # - Linux/macOS:
+        #    $ export SELFSERVICE_FLOWS_RECOVERY_AFTER_HOOKS=<value>
+        # - Windows Command Line (CMD):
+        #    > set SELFSERVICE_FLOWS_RECOVERY_AFTER_HOOKS=<value>
+        #
+        hooks:
+          - hook: web_hook
+            config: # web_hook specific configuration
+
       ## Self-Service Recovery Request Lifespan ##
       #
       # Sets how long the recovery request is valid. If expired, the user has to redo the flow.
@@ -579,6 +665,18 @@ selfservice:
       ## after ##
       #
       after:
+        ## hooks ##
+        #
+        # Set this value using environment variables on
+        # - Linux/macOS:
+        #    $ export SELFSERVICE_FLOWS_SETTINGS_AFTER_HOOKS=<value>
+        # - Windows Command Line (CMD):
+        #    > set SELFSERVICE_FLOWS_SETTINGS_AFTER_HOOKS=<value>
+        #
+        hooks:
+          - hook: web_hook
+            config: # web_hook specific configuration
+
         ## password ##
         #
         password:
@@ -592,6 +690,8 @@ selfservice:
           #
           hooks:
             - hook: verify
+            - hook: web_hook
+              config: # web_hook specific configuration
 
           ## Redirect browsers to set URL per default ##
           #
@@ -622,6 +722,8 @@ selfservice:
           #
           hooks:
             - hook: verify
+            - hook: web_hook
+              config: # web_hook specific configuration
 
           ## Redirect browsers to set URL per default ##
           #
diff --git a/docs/docs/self-service/flows/code/registration/samples/password.policy.txt b/docs/docs/self-service/flows/code/registration/samples/password.policy.txt
index 7292ffd8e..c17f3778e 100644
--- a/docs/docs/self-service/flows/code/registration/samples/password.policy.txt
+++ b/docs/docs/self-service/flows/code/registration/samples/password.policy.txt
@@ -14,7 +14,7 @@ $ curl -H "Accept: application/json" -s \
   "messages": [
     {
       "id": 4000005,
-      "text": "The password can not be used because the password has been found in data breaches and must no longer be used..",
+      "text": "The password can not be used because the password has been found in data breaches and must no longer be used.",
       "type": "error",
       "context": {
         "reason": "the password has been found in data breaches and must no longer be used."
diff --git a/docs/docs/self-service/flows/code/settings/samples/password.policy.txt b/docs/docs/self-service/flows/code/settings/samples/password.policy.txt
index 3100a606b..aeaeea522 100644
--- a/docs/docs/self-service/flows/code/settings/samples/password.policy.txt
+++ b/docs/docs/self-service/flows/code/settings/samples/password.policy.txt
@@ -15,7 +15,7 @@ $ curl -s -X GET \
   "messages": [
     {
       "id": 4000005,
-      "text": "The password can not be used because the password has been found in data breaches and must no longer be used..",
+      "text": "The password can not be used because the password has been found in data breaches and must no longer be used.",
       "type": "error",
       "context": {
         "reason": "the password has been found in data breaches and must no longer be used."
diff --git a/docs/docs/self-service/hooks.mdx b/docs/docs/self-service/hooks.mdx
index d50e9114c..3d1635e29 100644
--- a/docs/docs/self-service/hooks.mdx
+++ b/docs/docs/self-service/hooks.mdx
@@ -5,16 +5,182 @@ 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.
+- _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.
   - _After persisting:_ runs after the identity was saved in the database.
+- _After recovery:_ is executed after a password has been successfully
+  recovered.
 - _After settings:_ is executed when a settings was successful:
   - _Before persisting:_ runs before the identity is saved in the database.
   - _After persisting:_ runs after the identity was saved in the database.
+- _After verification:_ is executed when the verification of an account was
+  successful.
+
+There are hooks, which can be configured only for a particular flow (login,
+registration, settings, ...) method (password, oidc, profile) and hooks, which
+can be configured for all methods of any flow.
+
+To configure hooks for all methods of a particular flow, following pattern must
+be used:
+
+```yaml title="path/to/my/kratos.config.yml
+selfservice:
+  flows:
+    <login|registration|recovery|...>:
+      <before|after>:
+        hooks:
+          - hook: <hook 1 name>
+            <hook 1 specific configuration>
+          - hook: <hook 2 name>
+            <hook 2 specific configuration>
+          ...
+```
+
+Following pattern must be followed to configure hooks for a particular flow
+method:
+
+```yaml title="path/to/my/kratos.config.yml
+selfservice:
+  flows:
+    <login|registration|recovery|...>:
+      <before or after>:
+        <particular method>:
+          hooks:
+            - hook: <hook 1 name>
+              <hook 1 specific configuration>
+            - hook: <hook 2 name>
+              <hook 2 specific configuration>
+            ...
+```
+
+Hooks configured on a particular method level always override the hooks
+configured on the flow level. So given the following example
+
+```yaml title="path/to/my/kratos.config.yml
+selfservice:
+  flows:
+    login:
+      before:
+        hooks:
+          - hook: hook_1
+            # hook_1 specific configuration
+      after:
+        hooks:
+          - hook: hook_2
+            # hook_2 specific configuration
+        password:
+          hooks:
+            - hook: hook_3
+              # hook_3 specific configuration
+        # ...
+```
+
+`hook_1` is always run when the flow is started. On the finalization of the
+flow, `hook_2` is not run for the `password` method, but for the `oidc` method
+(for example). If the `password` method is used, then `hook_3` will run.
+
+## All flows
+
+Following hooks can be configured for all flows.
+
+### Web-Hooks
+
+Web-Hooks can be configured for every, but error and logout flow and can be
+configured on either the before/after level or at the level of a particular
+method. Support for Web-Hooks before execution of a method is currently
+supported only for registration and login methods.
+
+:::note
+
+As of today Web-Hooks cannot control the execution of the flow by
+patching/updating particular properties of flow specific object upon Web-Hook
+completion. It will however cancel the execution of the flow if the call to the
+Web-Hook end point fails either due to a network error or if the end point
+responses with an HTTP code > 300.
+
+:::
+
+The configuration for a web-hook goes into the `hooks` property either on the
+flow before/after, or the method level and has the following structure:
+
+```.yaml
+  - hook: web_hook
+    config:
+      url: https://test.kratos.ory.sh/after_verification_hook
+      method: POST # GET, DELETE, etc
+      body: /link/to/my/jsonnet/file
+      auth:
+        type: <some-type>
+        config:
+          <type-specific-config>
+```
+
+The configuration section of a web-hook consists of
+
+- `url` - the url, the web-hook should call (mandatory)
+- `method` - the HTTP method (GET, POST, ...), the web-hook should use
+  (mandatory)
+- `body` - path to a jsonnet template, used by the web-hook to render the
+  payload to send (optional). This property is ignored for HTTP `method`s, which
+  do not support sending of HTTP body payloads.
+- `auth` - configuration of authentication and authorization mechanisms to be
+  used by the web-hook
+
+Web-Hooks bind the `flow`, as well as request headers (`request_headers`),
+request method (`request_method`), and the request url (`request_url`) of the
+flow into the jsonnet template for all methods and execution paths (before and
+after). For the `after` execution path of all flows, it binds the `identity`
+object into the jsonnet template as well. These objects are available through a
+`ctx` object. E.g. to create a body looking like `{ user_id: <some-id> }` to be
+send to the web hook end point, the jsonnet template would look like this:
+`function(ctx) { user_id: ctx.identity.id }`
+
+#### Web-Hook Authentication and Authorization Mechanisms
+
+For `auth` following mechanisms are supported:
+
+- Authentication via an Api Key. Type must be set to `api-key`.
+- Authentication via Basic Authentication. Type must be set to `basic-auth`.
+
+For `api-key` the config looks as follows:
+
+```.yaml
+name: Some-Name
+value: The-Value-of-My-Key
+in: header # alternatively cookie
+```
+
+All properties are mandatory.
+
+For `basic-auth` the config looks as follows:
+
+```.yaml
+user: My-User
+password: My-Pass-Value
+```
+
+All properties are mandatory.
 
 ## Login
 
+Hooks running before or after successful user login are defined per Self-Service
+Registration Method in ORY Kratos' configuration file.
+
+### Before
+
+```yaml title="path/to/my/kratos.config.yml"
+selfservice:
+  flows:
+    login:
+      before:
+        hooks:
+          - hook: web_hook
+            # web-hook specific configuration
+```
+
 ### After
 
 ```yaml title="path/to/my/kratos.config.yml"
@@ -23,7 +189,8 @@ selfservice:
     login:
       after:
         password:
-          - hook: revoke_active_sessions
+          hooks:
+            - hook: revoke_active_sessions
 ```
 
 #### `revoke_active_sessions`
@@ -37,14 +204,27 @@ selfservice:
     login:
       after:
         <method>:
-          - hook: revoke_active_sessions
-            # can not be configured
+          hooks:
+            - hook: revoke_active_sessions
+              # can not be configured
 ```
 
 ## Registration
 
-Hooks running after successful user registration are defined per Self-Service
-Registration Method in Ory Kratos' configuration file.
+Hooks running before or after successful user registration are defined per
+Self-Service Registration Method in ORY Kratos' configuration file.
+
+### Before
+
+```yaml title="path/to/my/kratos.config.yml"
+selfservice:
+  flows:
+    registration:
+      before:
+        hooks:
+          - hook: web_hook
+            # web-hook specific configuration
+```
 
 ### After
 
@@ -54,9 +234,11 @@ selfservice:
     registration:
       after:
         oidc:
-          - hook: session
+          hooks:
+            - hook: session
         password:
-          - hook: session
+          hooks:
+            - hook: session
 ```
 
 #### `session`
@@ -96,8 +278,9 @@ selfservice:
     registration:
       after:
         <method>:
-          - hook: session
-            # can not be configured
+          hooks:
+            - hook: session
+              # can not be configured
 ```
 
 Depending on the registration flow type the behavior changes.
@@ -152,4 +335,4 @@ selfservice:
       after:
 ```
 
-No hooks are available for this flow at the moment.
+Only Web-Hooks hooks are available for this flow at the moment.
diff --git a/driver/config/.schema/config.schema.json b/driver/config/.schema/config.schema.json
index 12e84540f..338826e1e 100644
--- a/driver/config/.schema/config.schema.json
+++ b/driver/config/.schema/config.schema.json
@@ -84,6 +84,105 @@
         "hook"
       ]
     },
+    "WebHookAuthBasicAuthProperties": {
+      "properties": {
+        "type": {
+          "const": "basic-auth"
+        },
+        "config": {
+          "type": "object",
+          "properties": {
+            "user": {
+              "type": "string",
+              "description": "user name for basic auth"
+            },
+            "password": {
+              "type": "string",
+              "description": "password for basic auth"
+            }
+          },
+          "additionalProperties": false,
+          "required": ["user", "password"]
+        }
+      },
+      "additionalProperties": false,
+      "required": ["type", "config"]
+    },
+    "WebHookAuthApiKeyProperties": {
+      "properties": {
+        "type": {
+          "const": "api-key"
+        },
+        "config": {
+          "type": "object",
+          "properties": {
+            "name": {
+              "type": "string",
+              "description": "The name of the api key"
+            },
+            "value": {
+              "type": "string",
+              "description": "The value of the api key"
+            },
+            "in": {
+              "type": "string",
+              "description": "How the api key should be transferred",
+              "enum": ["header", "cookie"]
+            }
+          },
+          "additionalProperties": false,
+          "required": ["name", "value", "in"]
+        }
+      },
+      "additionalProperties": false,
+      "required": ["type", "config"]
+    },
+    "selfServiceWebHook": {
+      "type": "object",
+      "properties": {
+        "hook": {
+          "const": "web_hook"
+        },
+        "config": {
+          "type": "object",
+          "title": "Web-Hook Configuration",
+          "description": "Define what the hook should do",
+          "properties": {
+            "url": {
+              "type": "string",
+              "description": "The URL the Web-Hook should call",
+              "format": "uri"
+            },
+            "method": {
+              "type": "string",
+              "description": "The HTTP method to use (GET, POST, etc)."
+            },
+            "body": {
+              "type": "string",
+              "description": "Path to the jsonnet template used for payload generation. Only used for those HTTP methods, which support HTTP body payloads"
+            },
+            "auth": {
+              "type": "object",
+              "title": "Auth mechanisms",
+              "description": "Define which auth mechanism the Web-Hook should use",
+              "oneOf": [
+                {
+                  "$ref": "#/definitions/WebHookAuthApiKeyProperties"
+                },
+                {
+                  "$ref": "#/definitions/WebHookAuthBasicAuthProperties"
+                }
+              ]
+            },
+            "additionalProperties": false
+          },
+          "additionalProperties": false,
+          "required": ["url", "method"]
+        }
+      },
+      "additionalProperties": false,
+      "required": [ "hook", "config" ]
+    },
     "OIDCClaims": {
       "title": "OpenID Connect claims",
       "description": "The OpenID Connect claims and optionally their properties which should be included in the id_token or returned from the UserInfo Endpoint.",
@@ -289,6 +388,18 @@
         }
       }
     },
+    "selfServiceHooks": {
+      "type": "array",
+      "items": {
+        "anyOf": [
+          {
+            "$ref": "#/definitions/selfServiceWebHook"
+          }
+        ]
+      },
+      "uniqueItems": true,
+      "additionalItems": false
+    },
     "selfServiceAfterSettingsMethod": {
       "type": "object",
       "additionalProperties": false,
@@ -302,6 +413,9 @@
             "anyOf": [
               {
                 "$ref": "#/definitions/selfServiceVerifyHook"
+              },
+              {
+                "$ref": "#/definitions/selfServiceWebHook"
               }
             ]
           },
@@ -323,6 +437,9 @@
             "anyOf": [
               {
                 "$ref": "#/definitions/selfServiceSessionRevokerHook"
+              },
+              {
+                "$ref": "#/definitions/selfServiceWebHook"
               }
             ]
           },
@@ -344,6 +461,9 @@
             "anyOf": [
               {
                 "$ref": "#/definitions/selfServiceSessionIssuerHook"
+              },
+              {
+                "$ref": "#/definitions/selfServiceWebHook"
               }
             ]
           },
@@ -364,6 +484,18 @@
         },
         "profile": {
           "$ref": "#/definitions/selfServiceAfterSettingsMethod"
+        },
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
+        }
+      }
+    },
+    "selfServiceBeforeLogin": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
         }
       }
     },
@@ -379,6 +511,18 @@
         },
         "oidc": {
           "$ref": "#/definitions/selfServiceAfterLoginMethod"
+        },
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
+        }
+      }
+    },
+    "selfServiceBeforeRegistration": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
         }
       }
     },
@@ -394,8 +538,35 @@
         },
         "oidc": {
           "$ref": "#/definitions/selfServiceAfterRegistrationMethod"
+        },
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
         }
       }
+    },
+    "selfServiceAfterVerification": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "default_browser_return_url": {
+          "$ref": "#/definitions/defaultReturnTo"
+        },
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
+        }
+      }
+    },
+    "selfServiceAfterRecovery": {
+      "type": "object",
+      "properties": {
+        "default_browser_return_url": {
+          "$ref": "#/definitions/defaultReturnTo"
+        },
+        "hooks": {
+          "$ref": "#/definitions/selfServiceHooks"
+        }
+      },
+      "additionalProperties": false
     }
   },
   "properties": {
@@ -508,6 +679,9 @@
                     "1s"
                   ]
                 },
+                "before": {
+                  "$ref": "#/definitions/selfServiceBeforeRegistration"
+                },
                 "after": {
                   "$ref": "#/definitions/selfServiceAfterRegistration"
                 }
@@ -537,6 +711,9 @@
                     "1s"
                   ]
                 },
+                "before": {
+                  "$ref": "#/definitions/selfServiceBeforeLogin"
+                },
                 "after": {
                   "$ref": "#/definitions/selfServiceAfterLogin"
                 }
@@ -564,13 +741,7 @@
                   "default": "https://www.ory.sh/kratos/docs/fallback/verification"
                 },
                 "after": {
-                  "type": "object",
-                  "properties": {
-                    "default_browser_return_url": {
-                      "$ref": "#/definitions/defaultReturnTo"
-                    }
-                  },
-                  "additionalProperties": false
+                  "$ref": "#/definitions/selfServiceAfterVerification"
                 },
                 "lifespan": {
                   "title": "Self-Service Verification Request Lifespan",
@@ -608,13 +779,7 @@
                   "default": "https://www.ory.sh/kratos/docs/fallback/recovery"
                 },
                 "after": {
-                  "type": "object",
-                  "properties": {
-                    "default_browser_return_url": {
-                      "$ref": "#/definitions/defaultReturnTo"
-                    }
-                  },
-                  "additionalProperties": false
+                  "$ref": "#/definitions/selfServiceAfterRecovery"
                 },
                 "lifespan": {
                   "title": "Self-Service Recovery Request Lifespan",
@@ -787,7 +952,9 @@
               "title": "SMTP Sender Name",
               "description": "The recipient of an email will see this as the sender name.",
               "type": "string",
-              "examples": ["Bob"]
+              "examples": [
+                "Bob"
+              ]
             }
           },
           "required": [
@@ -1389,7 +1556,9 @@
           "title": "Configuration for the Bcrypt hasher. Minimum is 4 when --dev flag is used and 12 otherwise.",
           "type": "object",
           "additionalProperties": false,
-          "required": ["cost"],
+          "required": [
+            "cost"
+          ],
           "properties": {
             "cost": {
               "type": "integer",
diff --git a/driver/config/config.go b/driver/config/config.go
index 635057141..3b8b682b8 100644
--- a/driver/config/config.go
+++ b/driver/config/config.go
@@ -83,6 +83,7 @@ const (
 	ViperKeySelfServiceSettingsAfter                                = "selfservice.flows.settings.after"
 	ViperKeySelfServiceSettingsRequestLifespan                      = "selfservice.flows.settings.lifespan"
 	ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter        = "selfservice.flows.settings.privileged_session_max_age"
+	ViperKeySelfServiceRecoveryAfter                                = "selfservice.flows.recovery.after"
 	ViperKeySelfServiceRecoveryEnabled                              = "selfservice.flows.recovery.enabled"
 	ViperKeySelfServiceRecoveryUI                                   = "selfservice.flows.recovery.ui_url"
 	ViperKeySelfServiceRecoveryRequestLifespan                      = "selfservice.flows.recovery.lifespan"
@@ -91,6 +92,7 @@ const (
 	ViperKeySelfServiceVerificationUI                               = "selfservice.flows.verification.ui_url"
 	ViperKeySelfServiceVerificationRequestLifespan                  = "selfservice.flows.verification.lifespan"
 	ViperKeySelfServiceVerificationBrowserDefaultReturnTo           = "selfservice.flows.verification.after." + DefaultBrowserReturnURL
+	ViperKeySelfServiceVerificationAfter                            = "selfservice.flows.verification.after"
 	ViperKeyDefaultIdentitySchemaURL                                = "identity.default_schema_url"
 	ViperKeyIdentitySchemas                                         = "identity.schemas"
 	ViperKeyHasherAlgorithm                                         = "hashers.algorithm"
@@ -186,8 +188,14 @@ func (c *Argon2) MarshalJSON() ([]byte, error) {
 
 var Argon2DefaultParallelism = uint8(runtime.NumCPU() * 2)
 
+const HookGlobal = "global"
+
 func HookStrategyKey(key, strategy string) string {
-	return fmt.Sprintf("%s.%s.hooks", key, strategy)
+	if strategy == HookGlobal {
+		return fmt.Sprintf("%s.hooks", key)
+	} else {
+		return fmt.Sprintf("%s.%s.hooks", key, strategy)
+	}
 }
 
 func (s Schemas) FindSchemaByID(id string) (*Schema, error) {
@@ -584,7 +592,7 @@ func (p *Config) SelfPublicURL(r *http.Request) *url.URL {
 
 	var aliases []DomainAlias
 	if err := json.NewDecoder(bytes.NewBufferString(raw)).Decode(&aliases); err != nil {
-		p.l.WithError(err).WithField("config", raw).Errorf("Unable to unmarshal domain alias configuration, falling back to primary domain.")
+		p.l.WithError(err).WithField("config", raw).Warnf("Unable to unmarshal domain alias configuration, falling back to primary domain.")
 		return primary
 	}
 
@@ -753,6 +761,10 @@ func (p *Config) SelfServiceFlowVerificationReturnTo(defaultReturnTo *url.URL) *
 	return p.p.RequestURIF(ViperKeySelfServiceVerificationBrowserDefaultReturnTo, defaultReturnTo)
 }
 
+func (p *Config) SelfServiceFlowVerificationAfterHooks(strategy string) []SelfServiceHook {
+	return p.selfServiceHooks(HookStrategyKey(ViperKeySelfServiceVerificationAfter, strategy))
+}
+
 func (p *Config) SelfServiceFlowRecoveryReturnTo() *url.URL {
 	return p.p.RequestURIF(ViperKeySelfServiceRecoveryBrowserDefaultReturnTo, p.SelfServiceBrowserDefaultReturnTo())
 }
@@ -761,6 +773,10 @@ func (p *Config) SelfServiceFlowRecoveryRequestLifespan() time.Duration {
 	return p.p.DurationF(ViperKeySelfServiceRecoveryRequestLifespan, time.Hour)
 }
 
+func (p *Config) SelfServiceFlowRecoveryAfterHooks(strategy string) []SelfServiceHook {
+	return p.selfServiceHooks(HookStrategyKey(ViperKeySelfServiceRecoveryAfter, strategy))
+}
+
 func (p *Config) SelfServiceFlowSettingsPrivilegedSessionMaxAge() time.Duration {
 	return p.p.DurationF(ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Hour)
 }
diff --git a/driver/registry_default.go b/driver/registry_default.go
index 27a45ea78..40acaea26 100644
--- a/driver/registry_default.go
+++ b/driver/registry_default.go
@@ -106,14 +106,16 @@ type RegistryDefault struct {
 	selfserviceSettingsErrorHandler *settings.ErrorHandler
 	selfserviceSettingsExecutor     *settings.HookExecutor
 
-	selfserviceVerifyErrorHandler *verification.ErrorHandler
-	selfserviceVerifyManager      *identity.Manager
-	selfserviceVerifyHandler      *verification.Handler
+	selfserviceVerifyErrorHandler   *verification.ErrorHandler
+	selfserviceVerifyManager        *identity.Manager
+	selfserviceVerifyHandler        *verification.Handler
+	selfserviceVerificationExecutor *verification.HookExecutor
 
 	selfserviceLinkSender *link.Sender
 
 	selfserviceRecoveryErrorHandler *recovery.ErrorHandler
 	selfserviceRecoveryHandler      *recovery.Handler
+	selfserviceRecoveryExecutor     *recovery.HookExecutor
 
 	selfserviceLogoutHandler *logout.Handler
 
diff --git a/driver/registry_default_hooks.go b/driver/registry_default_hooks.go
index d51ae0c87..1d8fc649a 100644
--- a/driver/registry_default_hooks.go
+++ b/driver/registry_default_hooks.go
@@ -37,6 +37,8 @@ func (m *RegistryDefault) getHooks(credentialsType string, configs []config.Self
 			i = append(i, m.HookSessionIssuer())
 		case hook.KeySessionDestroyer:
 			i = append(i, m.HookSessionDestroyer())
+		case hook.KeyWebHook:
+			i = append(i, hook.NewWebHook(m, h.Config))
 		default:
 			var found bool
 			for name, m := range m.injectedSelfserviceHooks {
diff --git a/driver/registry_default_login.go b/driver/registry_default_login.go
index c2a94e652..d9bfedeeb 100644
--- a/driver/registry_default_login.go
+++ b/driver/registry_default_login.go
@@ -3,6 +3,7 @@ package driver
 import (
 	"context"
 
+	"github.com/ory/kratos/driver/config"
 	"github.com/ory/kratos/identity"
 	"github.com/ory/kratos/selfservice/flow/login"
 )
@@ -29,6 +30,16 @@ func (m *RegistryDefault) PostLoginHooks(ctx context.Context, credentialsType id
 			b = append(b, hook)
 		}
 	}
+
+	if len(b) == 0 {
+		// since we don't want merging hooks defined in a specific strategy and global hooks
+		// global hooks are added only if no strategy specific hooks are defined
+		for _, v := range m.getHooks(config.HookGlobal, m.Config(ctx).SelfServiceFlowLoginAfterHooks("global")) {
+			if hook, ok := v.(login.PostHookExecutor); ok {
+				b = append(b, hook)
+			}
+		}
+	}
 	return
 }
 
diff --git a/driver/registry_default_recovery.go b/driver/registry_default_recovery.go
index da826bdec..337f77e50 100644
--- a/driver/registry_default_recovery.go
+++ b/driver/registry_default_recovery.go
@@ -3,6 +3,7 @@ package driver
 import (
 	"context"
 
+	"github.com/ory/kratos/driver/config"
 	"github.com/ory/kratos/selfservice/flow/recovery"
 )
 
@@ -41,3 +42,20 @@ func (m *RegistryDefault) AllRecoveryStrategies() (recoveryStrategies recovery.S
 	}
 	return
 }
+
+func (m *RegistryDefault) RecoveryExecutor() *recovery.HookExecutor {
+	if m.selfserviceRecoveryExecutor == nil {
+		m.selfserviceRecoveryExecutor = recovery.NewHookExecutor(m)
+	}
+	return m.selfserviceRecoveryExecutor
+}
+
+func (m *RegistryDefault) PostRecoveryHooks(ctx context.Context) (b []recovery.PostHookExecutor) {
+	for _, v := range m.getHooks(config.HookGlobal, m.Config(ctx).SelfServiceFlowRecoveryAfterHooks(config.HookGlobal)) {
+		if hook, ok := v.(recovery.PostHookExecutor); ok {
+			b = append(b, hook)
+		}
+	}
+
+	return
+}
diff --git a/driver/registry_default_registration.go b/driver/registry_default_registration.go
index ae81df1cd..c1f4720d3 100644
--- a/driver/registry_default_registration.go
+++ b/driver/registry_default_registration.go
@@ -3,6 +3,7 @@ package driver
 import (
 	"context"
 
+	"github.com/ory/kratos/driver/config"
 	"github.com/ory/kratos/identity"
 	"github.com/ory/kratos/selfservice/flow/registration"
 )
@@ -13,12 +14,15 @@ func (m *RegistryDefault) PostRegistrationPrePersistHooks(ctx context.Context, c
 			b = append(b, hook)
 		}
 	}
+
 	return
 }
 
 func (m *RegistryDefault) PostRegistrationPostPersistHooks(ctx context.Context, credentialsType identity.CredentialsType) (b []registration.PostHookPostPersistExecutor) {
+	initialHookCount := 0
 	if m.Config(ctx).SelfServiceFlowVerificationEnabled() {
 		b = append(b, m.HookVerifier())
+		initialHookCount = 1
 	}
 
 	for _, v := range m.getHooks(string(credentialsType), m.Config(ctx).SelfServiceFlowRegistrationAfterHooks(string(credentialsType))) {
@@ -26,6 +30,17 @@ func (m *RegistryDefault) PostRegistrationPostPersistHooks(ctx context.Context,
 			b = append(b, hook)
 		}
 	}
+
+	if len(b) == initialHookCount {
+		// since we don't want merging hooks defined in a specific strategy and global hooks
+		// global hooks are added only if no strategy specific hooks are defined
+		for _, v := range m.getHooks(config.HookGlobal, m.Config(ctx).SelfServiceFlowRegistrationAfterHooks(config.HookGlobal)) {
+			if hook, ok := v.(registration.PostHookPostPersistExecutor); ok {
+				b = append(b, hook)
+			}
+		}
+	}
+
 	return
 }
 
diff --git a/driver/registry_default_settings.go b/driver/registry_default_settings.go
index 7229fafc8..ffd1a12ba 100644
--- a/driver/registry_default_settings.go
+++ b/driver/registry_default_settings.go
@@ -3,6 +3,7 @@ package driver
 import (
 	"context"
 
+	"github.com/ory/kratos/driver/config"
 	"github.com/ory/kratos/selfservice/flow/settings"
 )
 
@@ -16,8 +17,10 @@ func (m *RegistryDefault) PostSettingsPrePersistHooks(ctx context.Context, setti
 }
 
 func (m *RegistryDefault) PostSettingsPostPersistHooks(ctx context.Context, settingsType string) (b []settings.PostHookPostPersistExecutor) {
+	initialHookCount := 0
 	if m.Config(ctx).SelfServiceFlowVerificationEnabled() {
 		b = append(b, m.HookVerifier())
+		initialHookCount = 1
 	}
 
 	for _, v := range m.getHooks(settingsType, m.Config(ctx).SelfServiceFlowSettingsAfterHooks(settingsType)) {
@@ -25,6 +28,17 @@ func (m *RegistryDefault) PostSettingsPostPersistHooks(ctx context.Context, sett
 			b = append(b, hook)
 		}
 	}
+
+	if len(b) == initialHookCount {
+		// since we don't want merging hooks defined in a specific strategy and global hooks
+		// global hooks are added only if no strategy specific hooks are defined
+		for _, v := range m.getHooks(config.HookGlobal, m.Config(ctx).SelfServiceFlowSettingsAfterHooks(config.HookGlobal)) {
+			if hook, ok := v.(settings.PostHookPostPersistExecutor); ok {
+				b = append(b, hook)
+			}
+		}
+	}
+
 	return
 }
 
diff --git a/driver/registry_default_verify.go b/driver/registry_default_verify.go
index c0e3b6ece..d7572d744 100644
--- a/driver/registry_default_verify.go
+++ b/driver/registry_default_verify.go
@@ -3,6 +3,7 @@ package driver
 import (
 	"context"
 
+	"github.com/ory/kratos/driver/config"
 	"github.com/ory/kratos/identity"
 	"github.com/ory/kratos/selfservice/flow/verification"
 	"github.com/ory/kratos/selfservice/strategy/link"
@@ -61,5 +62,24 @@ func (m *RegistryDefault) AllVerificationStrategies() (recoveryStrategies verifi
 			recoveryStrategies = append(recoveryStrategies, s)
 		}
 	}
+
+	return
+}
+
+func (m *RegistryDefault) VerificationExecutor() *verification.HookExecutor {
+	if m.selfserviceVerificationExecutor == nil {
+		m.selfserviceVerificationExecutor = verification.NewHookExecutor(m)
+	}
+	return m.selfserviceVerificationExecutor
+}
+
+func (m *RegistryDefault) PostVerificationHooks(ctx context.Context) (b []verification.PostHookExecutor) {
+
+	for _, v := range m.getHooks(config.HookGlobal, m.Config(ctx).SelfServiceFlowVerificationAfterHooks(config.HookGlobal)) {
+		if hook, ok := v.(verification.PostHookExecutor); ok {
+			b = append(b, hook)
+		}
+	}
+
 	return
 }
diff --git a/go.mod b/go.mod
index b3f0e4dc0..6913e338b 100644
--- a/go.mod
+++ b/go.mod
@@ -44,7 +44,7 @@ require (
 	github.com/golang/gddo v0.0.0-20190904175337-72a348e765d2
 	github.com/golang/mock v1.5.0
 	github.com/google/go-github/v27 v27.0.1
-	github.com/google/go-jsonnet v0.16.0
+	github.com/google/go-jsonnet v0.17.0
 	github.com/google/uuid v1.2.0
 	github.com/gorilla/context v1.1.1
 	github.com/gorilla/sessions v1.1.3
diff --git a/go.sum b/go.sum
index b49f31fe3..8f4179a8e 100644
--- a/go.sum
+++ b/go.sum
@@ -688,6 +688,8 @@ github.com/google/go-github/v27 v27.0.1 h1:sSMFSShNn4VnqCqs+qhab6TS3uQc+uVR6TD1b
 github.com/google/go-github/v27 v27.0.1/go.mod h1:/0Gr8pJ55COkmv+S/yPKCczSkUPIM/LnFyubufRNIS0=
 github.com/google/go-jsonnet v0.16.0 h1:Nb4EEOp+rdeGGyB1rQ5eisgSAqrTnhf9ip+X6lzZbY0=
 github.com/google/go-jsonnet v0.16.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
+github.com/google/go-jsonnet v0.17.0 h1:/9NIEfhK1NQRKl3sP2536b2+x5HnZMdql7x3yK/l8JY=
+github.com/google/go-jsonnet v0.17.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw=
 github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
diff --git a/internal/.kratos.yaml b/internal/.kratos.yaml
index 35107ec99..49aeb87b2 100644
--- a/internal/.kratos.yaml
+++ b/internal/.kratos.yaml
@@ -71,6 +71,12 @@ selfservice:
       lifespan: 98m
       after:
         default_browser_return_url: http://test.kratos.ory.sh/dashboard
+        hooks:
+          - hook: web_hook
+            config:
+              url: https://test.kratos.ory.sh/after_recovery_hook
+              method: GET
+              body: /path/to/template.jsonnet
 
     verification:
       enabled: true
@@ -78,6 +84,12 @@ selfservice:
       ui_url: http://test.kratos.ory.sh/verification
       after:
         default_browser_return_url: http://test.kratos.ory.sh/dashboard
+        hooks:
+          - hook: web_hook
+            config:
+              url: https://test.kratos.ory.sh/after_verification_hook
+              method: GET
+              body: /path/to/template.jsonnet
 
     settings:
       ui_url:  http://test.kratos.ory.sh/settings
@@ -87,31 +99,103 @@ selfservice:
         default_browser_return_url: https://self-service/settings/return_to
         password:
           default_browser_return_url: https://self-service/settings/password/return_to
+          hooks:
+            - hook: web_hook
+              config:
+                url: https://test.kratos.ory.sh/after_settings_password_hook
+                method: POST
+                body: /path/to/template.jsonnet
+        profile:
+          hooks:
+            - hook: web_hook
+              config:
+                url: https://test.kratos.ory.sh/after_settings_profile_hook
+                method: POST
+                body: /path/to/template.jsonnet
+        hooks:
+          - hook: web_hook
+            config:
+              url: https://test.kratos.ory.sh/after_settings_global_hook
+              method: POST
+              body: /path/to/template.jsonnet
+
     login:
       ui_url: http://test.kratos.ory.sh/login
       lifespan: 99m
+      before:
+        hooks:
+          - hook: web_hook
+            config:
+              url: https://test.kratos.ory.sh/before_login_hook
+              method: POST
       after:
         default_browser_return_url: https://self-service/login/return_to
         password:
           default_browser_return_url: https://self-service/login/password/return_to
           hooks:
-            -
-              hook: revoke_active_sessions
+            - hook: revoke_active_sessions
+            - hook: web_hook
+              config:
+                url: https://test.kratos.ory.sh/after_login_password_hook
+                method: POST
+                body: /path/to/template.jsonnet
+                auth:
+                  type: basic-auth
+                  config:
+                    user: test-user
+                    password: super-secret
         oidc:
           hooks:
-            -
-              hook: revoke_active_sessions
+            - hook: web_hook
+              config:
+                url: https://test.kratos.ory.sh/after_login_oidc_hook
+                method: GET
+                body: /path/to/template.jsonnet
+            - hook: revoke_active_sessions
+        hooks:
+          - hook: web_hook
+            config:
+              url: https://test.kratos.ory.sh/after_login_global_hook
+              method: POST
+              body: /path/to/template.jsonnet
+
     registration:
       ui_url: http://test.kratos.ory.sh/register
       lifespan: 98m
+      before:
+        hooks:
+          - hook: web_hook
+            config:
+              url: https://test.kratos.ory.sh/before_registration_hook
+              method: GET
       after:
         default_browser_return_url: https://self-service/registration/return_to
         password:
           hooks:
-            -
-              hook: session
+            - hook: session
+            - hook: web_hook
+              config:
+                url: https://test.kratos.ory.sh/after_registration_password_hook
+                method: POST
+                body: /path/to/template.jsonnet
+        hooks:
+          - hook: web_hook
+            config:
+              url: https://test.kratos.ory.sh/after_registration_global_hook
+              method: POST
+              body: /path/to/template.jsonnet
+              auth:
+                type: api-key
+                config:
+                  name: My-Key
+                  value: My-Key-Value
+                  in: header
         oidc:
           default_browser_return_url: https://self-service/registration/oidc/return_to
           hooks:
-            -
-              hook: session
+            - hook: web_hook
+              config:
+                url: https://test.kratos.ory.sh/after_registration_oidc_hook
+                method: GET
+                body: /path/to/template.jsonnet
+            - hook: session
diff --git a/selfservice/flow/recovery/error.go b/selfservice/flow/recovery/error.go
index 17795f0f3..b6c168037 100644
--- a/selfservice/flow/recovery/error.go
+++ b/selfservice/flow/recovery/error.go
@@ -22,6 +22,7 @@ import (
 )
 
 var (
+	ErrHookAbortFlow   = errors.New("aborted recovery hook execution")
 	ErrAlreadyLoggedIn = herodot.ErrBadRequest.WithReason("A valid session was detected and thus recovery is not possible.")
 )
 
diff --git a/selfservice/flow/recovery/hook.go b/selfservice/flow/recovery/hook.go
new file mode 100644
index 000000000..d7074e2af
--- /dev/null
+++ b/selfservice/flow/recovery/hook.go
@@ -0,0 +1,87 @@
+package recovery
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/ory/kratos/driver/config"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/session"
+	"github.com/ory/kratos/x"
+)
+
+type (
+	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 {
+		PostRecoveryHooks(ctx context.Context) []PostHookExecutor
+	}
+)
+
+func PostHookRecoveryExecutorNames(e []PostHookExecutor) []string {
+	names := make([]string, len(e))
+	for k, ee := range e {
+		names[k] = fmt.Sprintf("%T", ee)
+	}
+	return names
+}
+
+func (f PostHookExecutorFunc) ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
+	return f(w, r, a, s)
+}
+
+type (
+	executorDependencies interface {
+		config.Provider
+		identity.ManagementProvider
+		identity.ValidationProvider
+		session.PersistenceProvider
+		HooksProvider
+		x.LoggingProvider
+		x.WriterProvider
+	}
+
+	HookExecutor struct {
+		d executorDependencies
+	}
+
+	HookExecutorProvider interface {
+		RecoveryExecutor() *HookExecutor
+	}
+)
+
+func NewHookExecutor(d executorDependencies) *HookExecutor {
+	return &HookExecutor{
+		d: d,
+	}
+}
+
+func (e *HookExecutor) PostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
+	e.d.Logger().
+		WithRequest(r).
+		WithField("identity_id", s.Identity.ID).
+		Debug("Running ExecutePostRecoveryHooks.")
+	for k, executor := range e.d.PostRecoveryHooks(r.Context()) {
+		if err := executor.ExecutePostRecoveryHook(w, r, a, s); err != nil {
+			return err
+		}
+
+		e.d.Logger().WithRequest(r).
+			WithField("executor", fmt.Sprintf("%T", executor)).
+			WithField("executor_position", k).
+			WithField("executors", PostHookRecoveryExecutorNames(e.d.PostRecoveryHooks(r.Context()))).
+			WithField("identity_id", s.Identity.ID).
+			Debug("ExecutePostVerificationHook completed successfully.")
+	}
+
+	e.d.Logger().
+		WithRequest(r).
+		WithField("identity_id", s.Identity.ID).
+		Debug("Post verification execution hooks completed successfully.")
+
+	return nil
+}
diff --git a/selfservice/flow/verification/error.go b/selfservice/flow/verification/error.go
index 2ebc0d094..264ab0d54 100644
--- a/selfservice/flow/verification/error.go
+++ b/selfservice/flow/verification/error.go
@@ -21,6 +21,10 @@ import (
 	"github.com/ory/kratos/x"
 )
 
+var (
+	ErrHookAbortFlow = errors.New("aborted verification hook execution")
+)
+
 type (
 	errorHandlerDependencies interface {
 		errorx.ManagementProvider
diff --git a/selfservice/flow/verification/hook.go b/selfservice/flow/verification/hook.go
new file mode 100644
index 000000000..d3f8601fd
--- /dev/null
+++ b/selfservice/flow/verification/hook.go
@@ -0,0 +1,87 @@
+package verification
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/ory/kratos/driver/config"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/session"
+	"github.com/ory/kratos/x"
+)
+
+type (
+	PostHookExecutor interface {
+		ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error
+	}
+	PostHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error
+
+	HooksProvider interface {
+		PostVerificationHooks(ctx context.Context) []PostHookExecutor
+	}
+)
+
+func PostHookVerificationExecutorNames(e []PostHookExecutor) []string {
+	names := make([]string, len(e))
+	for k, ee := range e {
+		names[k] = fmt.Sprintf("%T", ee)
+	}
+	return names
+}
+
+func (f PostHookExecutorFunc) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
+	return f(w, r, a, i)
+}
+
+type (
+	executorDependencies interface {
+		config.Provider
+		identity.ManagementProvider
+		identity.ValidationProvider
+		session.PersistenceProvider
+		HooksProvider
+		x.LoggingProvider
+		x.WriterProvider
+	}
+
+	HookExecutor struct {
+		d executorDependencies
+	}
+
+	HookExecutorProvider interface {
+		VerificationExecutor() *HookExecutor
+	}
+)
+
+func NewHookExecutor(d executorDependencies) *HookExecutor {
+	return &HookExecutor{
+		d: d,
+	}
+}
+
+func (e *HookExecutor) PostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
+	e.d.Logger().
+		WithRequest(r).
+		WithField("identity_id", i.ID).
+		Debug("Running ExecutePostVerificationHooks.")
+	for k, executor := range e.d.PostVerificationHooks(r.Context()) {
+		if err := executor.ExecutePostVerificationHook(w, r, a, i); err != nil {
+			return err
+		}
+
+		e.d.Logger().WithRequest(r).
+			WithField("executor", fmt.Sprintf("%T", executor)).
+			WithField("executor_position", k).
+			WithField("executors", PostHookVerificationExecutorNames(e.d.PostVerificationHooks(r.Context()))).
+			WithField("identity_id", i.ID).
+			Debug("ExecutePostVerificationHook completed successfully.")
+	}
+
+	e.d.Logger().
+		WithRequest(r).
+		WithField("identity_id", i.ID).
+		Debug("Post verification execution hooks completed successfully.")
+
+	return nil
+}
diff --git a/selfservice/hook/error.go b/selfservice/hook/error.go
index 2168f55ec..7de9f7ac3 100644
--- a/selfservice/hook/error.go
+++ b/selfservice/hook/error.go
@@ -5,6 +5,10 @@ import (
 	"errors"
 	"net/http"
 
+	"github.com/ory/kratos/selfservice/flow/verification"
+
+	"github.com/ory/kratos/selfservice/flow/recovery"
+
 	"github.com/tidwall/gjson"
 
 	"github.com/ory/kratos/identity"
@@ -67,3 +71,11 @@ func (e Error) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *
 func (e Error) ExecutePostRegistrationPrePersistHook(w http.ResponseWriter, r *http.Request, a *registration.Flow, i *identity.Identity) error {
 	return e.err("ExecutePostRegistrationPrePersistHook", registration.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) 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/hooks.go b/selfservice/hook/hooks.go
index 8cda4bddf..2525c0a3e 100644
--- a/selfservice/hook/hooks.go
+++ b/selfservice/hook/hooks.go
@@ -3,4 +3,5 @@ package hook
 const (
 	KeySessionIssuer    = "session"
 	KeySessionDestroyer = "revoke_active_sessions"
+	KeyWebHook          = "web_hook"
 )
diff --git a/selfservice/hook/stub/bad_template.jsonnet b/selfservice/hook/stub/bad_template.jsonnet
new file mode 100644
index 000000000..b1fe1b4d0
--- /dev/null
+++ b/selfservice/hook/stub/bad_template.jsonnet
@@ -0,0 +1,3 @@
+function(ctx) {
+  flow_id: ctx.foo,
+}
diff --git a/selfservice/hook/web_hook.go b/selfservice/hook/web_hook.go
new file mode 100644
index 000000000..a6c4b04c6
--- /dev/null
+++ b/selfservice/hook/web_hook.go
@@ -0,0 +1,315 @@
+package hook
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/ory/kratos/selfservice/flow"
+
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/selfservice/flow/settings"
+
+	"github.com/ory/kratos/selfservice/flow/login"
+
+	"github.com/ory/kratos/selfservice/flow/recovery"
+
+	"github.com/google/go-jsonnet"
+
+	"github.com/ory/kratos/selfservice/flow/registration"
+	"github.com/ory/kratos/selfservice/flow/verification"
+	"github.com/ory/kratos/session"
+	"github.com/ory/kratos/x"
+)
+
+var _ registration.PostHookPostPersistExecutor = new(WebHook)
+var _ verification.PostHookExecutor = new(WebHook)
+var _ recovery.PostHookExecutor = new(WebHook)
+
+type (
+	AuthStrategy interface {
+		apply(req *http.Request)
+	}
+
+	authStrategyFactory func(c json.RawMessage) (AuthStrategy, error)
+
+	noopAuthStrategy struct{}
+
+	basicAuthStrategy struct {
+		user     string
+		password string
+	}
+
+	apiKeyStrategy struct {
+		name  string
+		value string
+		in    string
+	}
+
+	webHookConfig struct {
+		method       string
+		url          string
+		templatePath string
+		auth         AuthStrategy
+	}
+
+	webHookDependencies interface {
+		x.LoggingProvider
+	}
+
+	templateContext struct {
+		Flow           flow.Flow          `json:"flow"`
+		RequestHeaders http.Header        `json:"request_headers"`
+		RequestMethod  string             `json:"request_method"`
+		RequestUrl     string             `json:"request_url"`
+		Identity       *identity.Identity `json:"identity"`
+	}
+
+	WebHook struct {
+		r webHookDependencies
+		c json.RawMessage
+	}
+)
+
+var strategyFactories = map[string]authStrategyFactory{
+	"":           newNoopAuthStrategy,
+	"api-key":    newApiKeyStrategy,
+	"basic-auth": newBasicAuthStrategy,
+}
+
+func newAuthStrategy(name string, c json.RawMessage) (as AuthStrategy, err error) {
+	if f, ok := strategyFactories[name]; ok {
+		as, err = f(c)
+	} else {
+		err = fmt.Errorf("unsupported auth type: %s", name)
+	}
+	return
+}
+
+func newNoopAuthStrategy(_ json.RawMessage) (AuthStrategy, error) {
+	return &noopAuthStrategy{}, nil
+}
+
+func (c *noopAuthStrategy) apply(_ *http.Request) {}
+
+func newBasicAuthStrategy(raw json.RawMessage) (AuthStrategy, error) {
+	type config struct {
+		User     string
+		Password string
+	}
+
+	var c config
+	if err := json.Unmarshal(raw, &c); err != nil {
+		return nil, err
+	}
+
+	return &basicAuthStrategy{
+		user:     c.User,
+		password: c.Password,
+	}, nil
+}
+
+func (c *basicAuthStrategy) apply(req *http.Request) {
+	req.SetBasicAuth(c.user, c.password)
+}
+
+func newApiKeyStrategy(raw json.RawMessage) (AuthStrategy, error) {
+	type config struct {
+		In    string
+		Name  string
+		Value string
+	}
+
+	var c config
+	if err := json.Unmarshal(raw, &c); err != nil {
+		return nil, err
+	}
+
+	return &apiKeyStrategy{
+		in:    c.In,
+		name:  c.Name,
+		value: c.Value,
+	}, nil
+}
+
+func (c *apiKeyStrategy) apply(req *http.Request) {
+	switch c.in {
+	case "cookie":
+		req.AddCookie(&http.Cookie{Name: c.name, Value: c.value})
+	default:
+		req.Header.Set(c.name, c.value)
+	}
+}
+
+func newWebHookConfig(r json.RawMessage) (*webHookConfig, error) {
+	type rawWebHookConfig struct {
+		Method string
+		Url    string
+		Body   string
+		Auth   struct {
+			Type   string
+			Config json.RawMessage
+		}
+	}
+
+	var rc rawWebHookConfig
+	err := json.Unmarshal(r, &rc)
+	if err != nil {
+		return nil, err
+	}
+
+	as, err := newAuthStrategy(rc.Auth.Type, rc.Auth.Config)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create web hook auth strategy: %w", err)
+	}
+
+	return &webHookConfig{
+		method:       rc.Method,
+		url:          rc.Url,
+		templatePath: rc.Body,
+		auth:         as,
+	}, nil
+}
+
+func NewWebHook(r webHookDependencies, c json.RawMessage) *WebHook {
+	return &WebHook{r: r, c: c}
+}
+
+func (e *WebHook) ExecuteLoginPreHook(_ http.ResponseWriter, req *http.Request, flow *login.Flow) error {
+	return e.execute(&templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestUrl:     req.RequestURI,
+	})
+}
+
+func (e *WebHook) ExecuteLoginPostHook(_ http.ResponseWriter, req *http.Request, flow *login.Flow, session *session.Session) error {
+	return e.execute(&templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestUrl:     req.RequestURI,
+		Identity:       session.Identity,
+	})
+}
+
+func (e *WebHook) ExecutePostVerificationHook(_ http.ResponseWriter, req *http.Request, flow *verification.Flow, identity *identity.Identity) error {
+	return e.execute(&templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestUrl:     req.RequestURI,
+		Identity:       identity,
+	})
+}
+
+func (e *WebHook) ExecutePostRecoveryHook(_ http.ResponseWriter, req *http.Request, flow *recovery.Flow, session *session.Session) error {
+	return e.execute(&templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestUrl:     req.RequestURI,
+		Identity:       session.Identity,
+	})
+}
+
+func (e *WebHook) ExecuteRegistrationPreHook(_ http.ResponseWriter, req *http.Request, flow *registration.Flow) error {
+	return e.execute(&templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestUrl:     req.RequestURI,
+	})
+}
+
+func (e *WebHook) ExecutePostRegistrationPostPersistHook(_ http.ResponseWriter, req *http.Request, flow *registration.Flow, session *session.Session) error {
+	return e.execute(&templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestUrl:     req.RequestURI,
+		Identity:       session.Identity,
+	})
+}
+
+func (e *WebHook) ExecuteSettingsPostPersistHook(_ http.ResponseWriter, req *http.Request, flow *settings.Flow, identity *identity.Identity) error {
+	return e.execute(&templateContext{
+		Flow:           flow,
+		RequestHeaders: req.Header,
+		RequestMethod:  req.Method,
+		RequestUrl:     req.RequestURI,
+		Identity:       identity,
+	})
+}
+
+func (e *WebHook) execute(data *templateContext) error {
+	// TODO: reminder for the future: move parsing of config to the web hook initialization
+	conf, err := newWebHookConfig(e.c)
+	if err != nil {
+		return fmt.Errorf("failed to parse web hook config: %w", err)
+	}
+
+	var body io.Reader
+	if conf.method != "TRACE" {
+		// According to the HTTP spec any request method, but TRACE is allowed to
+		// have a body. Even this is a really bad practice for some of them, like for
+		// GET
+		body, err = createBody(conf.templatePath, data)
+		if err != nil {
+			return fmt.Errorf("failed to create web hook body: %w", err)
+		}
+	}
+
+	if err = doHttpCall(conf.method, conf.url, conf.auth, body); err != nil {
+		return fmt.Errorf("failed to call web hook %w", err)
+	}
+	return nil
+}
+
+func createBody(templatePath string, data *templateContext) (io.Reader, error) {
+	var body io.Reader
+	if len(templatePath) == 0 {
+		return body, nil
+	}
+
+	vm := jsonnet.MakeVM()
+
+	buf := new(bytes.Buffer)
+	enc := json.NewEncoder(buf)
+	enc.SetEscapeHTML(false)
+	enc.SetIndent("", "")
+
+	if err := enc.Encode(data); err != nil {
+		return nil, err
+	}
+	vm.TLACode("ctx", buf.String())
+
+	if res, err := vm.EvaluateFile(templatePath); err != nil {
+		return nil, err
+	} else {
+		return bytes.NewReader([]byte(res)), nil
+	}
+}
+
+func doHttpCall(method string, url string, as AuthStrategy, body io.Reader) error {
+	req, err := http.NewRequest(method, url, body)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	as.apply(req)
+
+	resp, err := http.DefaultClient.Do(req)
+
+	if err != nil {
+		return err
+	} else if resp.StatusCode >= 400 {
+		return fmt.Errorf("web hook failed with status code %v", resp.StatusCode)
+	}
+
+	return nil
+}
diff --git a/selfservice/strategy/link/strategy.go b/selfservice/strategy/link/strategy.go
index 17b331649..ab20019ce 100644
--- a/selfservice/strategy/link/strategy.go
+++ b/selfservice/strategy/link/strategy.go
@@ -55,10 +55,12 @@ type (
 		recovery.ErrorHandlerProvider
 		recovery.FlowPersistenceProvider
 		recovery.StrategyProvider
+		recovery.HookExecutorProvider
 
 		verification.ErrorHandlerProvider
 		verification.FlowPersistenceProvider
 		verification.StrategyProvider
+		verification.HookExecutorProvider
 
 		RecoveryTokenPersistenceProvider
 		VerificationTokenPersistenceProvider
diff --git a/selfservice/strategy/link/strategy_recovery.go b/selfservice/strategy/link/strategy_recovery.go
index 0291d5e72..1d78d73ae 100644
--- a/selfservice/strategy/link/strategy_recovery.go
+++ b/selfservice/strategy/link/strategy_recovery.go
@@ -333,6 +333,10 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request,
 		return s.handleRecoveryError(w, r, f, nil, err)
 	}
 
+	if err := s.d.RecoveryExecutor().PostRecoveryHook(w, r, f, sess); err != nil {
+		return s.handleRecoveryError(w, r, f, nil, err)
+	}
+
 	sf.UI.Messages.Set(text.NewRecoverySuccessful(time.Now().Add(s.d.Config(r.Context()).SelfServiceFlowSettingsPrivilegedSessionMaxAge())))
 	if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(r.Context(), sf); err != nil {
 		return s.handleRecoveryError(w, r, f, nil, err)
diff --git a/selfservice/strategy/link/strategy_verification.go b/selfservice/strategy/link/strategy_verification.go
index 02580a92b..5b306527d 100644
--- a/selfservice/strategy/link/strategy_verification.go
+++ b/selfservice/strategy/link/strategy_verification.go
@@ -242,6 +242,15 @@ func (s *Strategy) verificationUseToken(w http.ResponseWriter, r *http.Request,
 		return s.handleVerificationError(w, r, f, body, err)
 	}
 
+	i, err := s.d.IdentityPool().GetIdentity(r.Context(), token.VerifiableAddress.IdentityID)
+	if err != nil {
+		return s.handleVerificationError(w, r, f, body, err)
+	}
+
+	if err := s.d.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil {
+		return s.handleVerificationError(w, r, f, body, err)
+	}
+
 	address := token.VerifiableAddress
 	address.Verified = true
 	address.VerifiedAt = sqlxx.NullTime(time.Now().UTC())
diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go
index ef7f66de4..6353fdce5 100644
--- a/selfservice/strategy/oidc/strategy_registration.go
+++ b/selfservice/strategy/oidc/strategy_registration.go
@@ -140,7 +140,7 @@ func (s *Strategy) processRegistration(w http.ResponseWriter, r *http.Request, a
 
 	vm := jsonnet.MakeVM()
 	vm.ExtCode("claims", jsonClaims.String())
-	evaluated, err := vm.EvaluateSnippet(provider.Config().Mapper, jn.String())
+	evaluated, err := vm.EvaluateAnonymousSnippet(provider.Config().Mapper, jn.String())
 	if err != nil {
 		return nil, s.handleError(w, r, a, provider.Config().ID, nil, err)
 	} else if traits := gjson.Get(evaluated, "identity.traits"); !traits.IsObject() {
diff --git a/selfservice/strategy/password/validator.go b/selfservice/strategy/password/validator.go
index 7242027a5..f2c4fc075 100644
--- a/selfservice/strategy/password/validator.go
+++ b/selfservice/strategy/password/validator.go
@@ -179,7 +179,7 @@ func (s *DefaultPasswordValidator) Validate(ctx context.Context, identifier, pas
 	}
 
 	if c > int64(s.reg.Config(ctx).PasswordPolicyConfig().MaxBreaches) {
-		return errors.New("the password has been found in data breaches and must no longer be used.")
+		return errors.New("the password has been found in data breaches and must no longer be used")
 	}
 
 	return nil


Test patch:
diff --git a/driver/config/config_test.go b/driver/config/config_test.go
index 79724d85a..a4a28d95d 100644
--- a/driver/config/config_test.go
+++ b/driver/config/config_test.go
@@ -157,8 +157,14 @@ func TestViperProvider(t *testing.T) {
 			assert.Equal(t, time.Minute*98, p.SelfServiceFlowRegistrationRequestLifespan())
 
 			t.Run("hook=before", func(t *testing.T) {
-				hook := p.SelfServiceFlowRegistrationBeforeHooks()
-				require.Len(t, hook, 0)
+				expHooks := []config.SelfServiceHook{
+					{Name: "web_hook", Config: json.RawMessage(`{"method":"GET","url":"https://test.kratos.ory.sh/before_registration_hook"}`)},
+				}
+
+				hooks := p.SelfServiceFlowRegistrationBeforeHooks()
+
+				require.Len(t, hooks, 1)
+				assert.Equal(t, expHooks, hooks)
 				// assert.EqualValues(t, "redirect", hook.Name)
 				// assert.JSONEq(t, `{"allow_user_defined_redirect":false,"default_redirect_url":"http://test.kratos.ory.sh:4000/"}`, string(hook.Config))
 			})
@@ -171,6 +177,7 @@ func TestViperProvider(t *testing.T) {
 					strategy: "password",
 					hooks: []config.SelfServiceHook{
 						{Name: "session", Config: json.RawMessage(`{}`)},
+						{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"POST","url":"https://test.kratos.ory.sh/after_registration_password_hook"}`)},
 						// {Name: "verify", Config: json.RawMessage(`{}`)},
 						// {Name: "redirect", Config: json.RawMessage(`{"allow_user_defined_redirect":false,"default_redirect_url":"http://test.kratos.ory.sh:4000/"}`)},
 					},
@@ -179,10 +186,17 @@ func TestViperProvider(t *testing.T) {
 					strategy: "oidc",
 					hooks: []config.SelfServiceHook{
 						// {Name: "verify", Config: json.RawMessage(`{}`)},
+						{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"GET","url":"https://test.kratos.ory.sh/after_registration_oidc_hook"}`)},
 						{Name: "session", Config: json.RawMessage(`{}`)},
 						// {Name: "redirect", Config: json.RawMessage(`{"allow_user_defined_redirect":false,"default_redirect_url":"http://test.kratos.ory.sh:4000/"}`)},
 					},
 				},
+				{
+					strategy: config.HookGlobal,
+					hooks: []config.SelfServiceHook{
+						{Name: "web_hook", Config: json.RawMessage(`{"auth":{"config":{"in":"header","name":"My-Key","value":"My-Key-Value"},"type":"api-key"},"body":"/path/to/template.jsonnet","method":"POST","url":"https://test.kratos.ory.sh/after_registration_global_hook"}`)},
+					},
+				},
 			} {
 				t.Run("hook=after/strategy="+tc.strategy, func(t *testing.T) {
 					hooks := p.SelfServiceFlowRegistrationAfterHooks(tc.strategy)
@@ -195,8 +209,14 @@ func TestViperProvider(t *testing.T) {
 			assert.Equal(t, time.Minute*99, p.SelfServiceFlowLoginRequestLifespan())
 
 			t.Run("hook=before", func(t *testing.T) {
-				hook := p.SelfServiceFlowLoginBeforeHooks()
-				require.Len(t, hook, 0)
+				expHooks := []config.SelfServiceHook{
+					{Name: "web_hook", Config: json.RawMessage(`{"method":"POST","url":"https://test.kratos.ory.sh/before_login_hook"}`)},
+				}
+
+				hooks := p.SelfServiceFlowLoginBeforeHooks()
+
+				require.Len(t, hooks, 1)
+				assert.Equal(t, expHooks, hooks)
 				// assert.EqualValues(t, "redirect", hook.Name)
 				// assert.JSONEq(t, `{"allow_user_defined_redirect":false,"default_redirect_url":"http://test.kratos.ory.sh:4000/"}`, string(hook.Config))
 			})
@@ -209,14 +229,22 @@ func TestViperProvider(t *testing.T) {
 					strategy: "password",
 					hooks: []config.SelfServiceHook{
 						{Name: "revoke_active_sessions", 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"}`)},
 					},
 				},
 				{
 					strategy: "oidc",
 					hooks: []config.SelfServiceHook{
+						{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"GET","url":"https://test.kratos.ory.sh/after_login_oidc_hook"}`)},
 						{Name: "revoke_active_sessions", Config: json.RawMessage(`{}`)},
 					},
 				},
+				{
+					strategy: config.HookGlobal,
+					hooks: []config.SelfServiceHook{
+						{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"POST","url":"https://test.kratos.ory.sh/after_login_global_hook"}`)},
+					},
+				},
 			} {
 				t.Run("hook=after/strategy="+tc.strategy, func(t *testing.T) {
 					hooks := p.SelfServiceFlowLoginAfterHooks(tc.strategy)
@@ -229,25 +257,26 @@ func TestViperProvider(t *testing.T) {
 			assert.Equal(t, time.Minute*99, p.SelfServiceFlowSettingsFlowLifespan())
 			assert.Equal(t, time.Minute*5, p.SelfServiceFlowSettingsPrivilegedSessionMaxAge())
 
-			t.Run("hook=before", func(t *testing.T) {
-				hook := p.SelfServiceFlowLoginBeforeHooks()
-				require.Len(t, hook, 0)
-				// assert.EqualValues(t, "redirect", hook.Name)
-				// assert.JSONEq(t, `{"allow_user_defined_redirect":false,"default_redirect_url":"http://test.kratos.ory.sh:4000/"}`, string(hook.Config))
-			})
-
 			for _, tc := range []struct {
 				strategy string
 				hooks    []config.SelfServiceHook
 			}{
 				{
 					strategy: "password",
-					hooks:    []config.SelfServiceHook{},
+					hooks: []config.SelfServiceHook{
+						{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"POST","url":"https://test.kratos.ory.sh/after_settings_password_hook"}`)},
+					},
 				},
 				{
 					strategy: "profile",
-					hooks:    []config.SelfServiceHook{
-						// {Name: "verify", Config: json.RawMessage(`{}`)},
+					hooks: []config.SelfServiceHook{
+						{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"POST","url":"https://test.kratos.ory.sh/after_settings_profile_hook"}`)},
+					},
+				},
+				{
+					strategy: config.HookGlobal,
+					hooks: []config.SelfServiceHook{
+						{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"POST","url":"https://test.kratos.ory.sh/after_settings_global_hook"}`)},
 					},
 				},
 			} {
@@ -259,13 +288,20 @@ func TestViperProvider(t *testing.T) {
 		})
 
 		t.Run("method=recovery", func(t *testing.T) {
+			assert.Equal(t, true, p.SelfServiceFlowRecoveryEnabled())
 			assert.Equal(t, time.Minute*98, p.SelfServiceFlowRecoveryRequestLifespan())
 			assert.Equal(t, "http://test.kratos.ory.sh/recovery", p.SelfServiceFlowRecoveryUI().String())
+
+			hooks := p.SelfServiceFlowRecoveryAfterHooks(config.HookGlobal)
+			assert.Equal(t, []config.SelfServiceHook{{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"GET","url":"https://test.kratos.ory.sh/after_recovery_hook"}`)}}, hooks)
 		})
 
 		t.Run("method=verification", func(t *testing.T) {
 			assert.Equal(t, time.Minute*97, p.SelfServiceFlowVerificationRequestLifespan())
 			assert.Equal(t, "http://test.kratos.ory.sh/verification", p.SelfServiceFlowVerificationUI().String())
+
+			hooks := p.SelfServiceFlowVerificationAfterHooks(config.HookGlobal)
+			assert.Equal(t, []config.SelfServiceHook{{Name: "web_hook", Config: json.RawMessage(`{"body":"/path/to/template.jsonnet","method":"GET","url":"https://test.kratos.ory.sh/after_verification_hook"}`)}}, hooks)
 		})
 
 		t.Run("group=hashers", func(t *testing.T) {
diff --git a/driver/registry_default_test.go b/driver/registry_default_test.go
index a6a70f43e..386ce0ebf 100644
--- a/driver/registry_default_test.go
+++ b/driver/registry_default_test.go
@@ -2,9 +2,14 @@ package driver_test
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"testing"
 
+	"github.com/ory/kratos/selfservice/flow/recovery"
+
+	"github.com/ory/kratos/selfservice/flow/verification"
+
 	"github.com/ory/kratos/driver"
 	"github.com/ory/x/configx"
 	"github.com/ory/x/logrusx"
@@ -23,43 +28,433 @@ import (
 
 func TestDriverDefault_Hooks(t *testing.T) {
 	ctx := context.Background()
-	t.Run("case=verification", func(t *testing.T) {
-		conf, reg := internal.NewFastRegistryWithMocks(t)
-		conf.MustSet(config.ViperKeySelfServiceVerificationEnabled, true)
-
-		t.Run("type=registration", func(t *testing.T) {
-			h := reg.PostRegistrationPostPersistHooks(ctx, identity.CredentialsTypePassword)
-			require.Len(t, h, 1)
-			assert.Equal(t, []registration.PostHookPostPersistExecutor{hook.NewVerifier(reg)}, h)
-
-			conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+".password.hooks",
-				[]map[string]interface{}{{"hook": "session"}})
-
-			h = reg.PostRegistrationPostPersistHooks(ctx, identity.CredentialsTypePassword)
-			require.Len(t, h, 2)
-			assert.Equal(t, []registration.PostHookPostPersistExecutor{
-				hook.NewVerifier(reg),
-				hook.NewSessionIssuer(reg),
-			}, h)
-		})
 
-		t.Run("type=login", func(t *testing.T) {
-			h := reg.PostLoginHooks(ctx, identity.CredentialsTypePassword)
-			require.Len(t, h, 0)
+	t.Run("type=verification", func(t *testing.T) {
+		// AFTER hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []verification.PostHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []verification.PostHookExecutor { return nil },
+			},
+			{
+				uc: "Multiple web_hooks configured",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceVerificationAfter+".hooks", []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.PostHookExecutor {
+					return []verification.PostHookExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("after/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
 
-			conf.MustSet(config.ViperKeySelfServiceLoginAfter+".password.hooks",
-				[]map[string]interface{}{{"hook": "revoke_active_sessions"}})
+				h := reg.PostVerificationHooks(ctx)
 
-			h = reg.PostLoginHooks(ctx, identity.CredentialsTypePassword)
-			require.Len(t, h, 1)
-			assert.Equal(t, []login.PostHookExecutor{hook.NewSessionDestroyer(reg)}, h)
-		})
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+	})
 
-		t.Run("type=settings", func(t *testing.T) {
-			h := reg.PostSettingsPostPersistHooks(ctx, "profile")
-			require.Len(t, h, 1)
-			assert.Equal(t, []settings.PostHookPostPersistExecutor{hook.NewVerifier(reg)}, h)
-		})
+	t.Run("type=recovery", func(t *testing.T) {
+		// AFTER hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []recovery.PostHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []recovery.PostHookExecutor { return nil },
+			},
+			{
+				uc: "Multiple web_hooks configured",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceRecoveryAfter+".hooks", []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.PostHookExecutor {
+					return []recovery.PostHookExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("after/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
+
+				h := reg.PostRecoveryHooks(ctx)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+	})
+
+	t.Run("type=registration", func(t *testing.T) {
+		// BEFORE hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []registration.PreHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []registration.PreHookExecutor { return nil },
+			},
+			{
+				uc: "Two web_hooks are configured",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceRegistrationBeforeHooks, []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) []registration.PreHookExecutor {
+					return []registration.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.PreRegistrationHooks(ctx)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+
+		// AFTER hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []registration.PostHookPostPersistExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []registration.PostHookPostPersistExecutor { return nil },
+			},
+			{
+				uc: "Only session hook configured for password strategy",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceVerificationEnabled, true)
+					conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+".password.hooks", []map[string]interface{}{
+						{"hook": "session"},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []registration.PostHookPostPersistExecutor {
+					return []registration.PostHookPostPersistExecutor{
+						hook.NewVerifier(reg),
+						hook.NewSessionIssuer(reg),
+					}
+				},
+			},
+			{
+				uc: "A session hook and a web_hook are configured for password strategy",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceVerificationEnabled, true)
+					conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+".password.hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST", "body": "bar"}},
+						{"hook": "session"},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []registration.PostHookPostPersistExecutor {
+					return []registration.PostHookPostPersistExecutor{
+						hook.NewVerifier(reg),
+						hook.NewWebHook(reg, json.RawMessage(`{"body":"bar","method":"POST","url":"foo"}`)),
+						hook.NewSessionIssuer(reg),
+					}
+				},
+			},
+			{
+				uc: "Two web_hooks are configured on a global level",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+".hooks", []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) []registration.PostHookPostPersistExecutor {
+					return []registration.PostHookPostPersistExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+			{
+				uc: "Hooks are configured on a global level, as well as on a strategy level",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+".password.hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "GET"}},
+						{"hook": "session"},
+					})
+					conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "bar", "method": "POST"}},
+					})
+					conf.MustSet(config.ViperKeySelfServiceVerificationEnabled, true)
+				},
+				expect: func(reg *driver.RegistryDefault) []registration.PostHookPostPersistExecutor {
+					return []registration.PostHookPostPersistExecutor{
+						hook.NewVerifier(reg),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"foo"}`)),
+						hook.NewSessionIssuer(reg),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("after/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
+
+				h := reg.PostRegistrationPostPersistHooks(ctx, identity.CredentialsTypePassword)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+	})
+
+	t.Run("type=login", func(t *testing.T) {
+		// BEFORE hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []login.PreHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []login.PreHookExecutor { return nil },
+			},
+			{
+				uc: "Two web_hooks are configured",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceLoginBeforeHooks, []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) []login.PreHookExecutor {
+					return []login.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.PreLoginHooks(ctx)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+
+		// AFTER hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []login.PostHookExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []login.PostHookExecutor { return nil },
+			},
+			{
+				uc: "Only revoke_active_sessions hook configured for password strategy",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".password.hooks", []map[string]interface{}{
+						{"hook": "revoke_active_sessions"},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []login.PostHookExecutor {
+					return []login.PostHookExecutor{
+						hook.NewSessionDestroyer(reg),
+					}
+				},
+			},
+			{
+				uc: "A revoke_active_sessions 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": "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.NewSessionDestroyer(reg),
+					}
+				},
+			},
+			{
+				uc: "Two web_hooks are configured on a global level",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".hooks", []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) []login.PostHookExecutor {
+					return []login.PostHookExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+			{
+				uc: "Hooks are configured on a global level, as well as on a strategy level",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".password.hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "GET"}},
+						{"hook": "revoke_active_sessions"},
+					})
+					conf.MustSet(config.ViperKeySelfServiceLoginAfter+".hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST"}},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []login.PostHookExecutor {
+					return []login.PostHookExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"foo"}`)),
+						hook.NewSessionDestroyer(reg),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("after/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
+
+				h := reg.PostLoginHooks(ctx, identity.CredentialsTypePassword)
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
+	})
+
+	t.Run("type=settings", func(t *testing.T) {
+		// AFTER hooks
+		for _, tc := range []struct {
+			uc     string
+			prep   func(conf *config.Config)
+			expect func(reg *driver.RegistryDefault) []settings.PostHookPostPersistExecutor
+		}{
+			{
+				uc:     "No hooks configured",
+				prep:   func(conf *config.Config) {},
+				expect: func(reg *driver.RegistryDefault) []settings.PostHookPostPersistExecutor { return nil },
+			},
+			{
+				uc: "Only verify hook configured for the strategy",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceVerificationEnabled, true)
+					// I think this is a bug as there is a hook named verify defined for both profile and password
+					// strategies. Instead of using it, the code makes use of the property used above and which
+					// is defined in an entirely different flow (verification).
+				},
+				expect: func(reg *driver.RegistryDefault) []settings.PostHookPostPersistExecutor {
+					return []settings.PostHookPostPersistExecutor{
+						hook.NewVerifier(reg),
+					}
+				},
+			},
+			{
+				uc: "A verify hook and a web_hook are configured for profile strategy",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceSettingsAfter+".profile.hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST", "body": "bar"}},
+					})
+					conf.MustSet(config.ViperKeySelfServiceVerificationEnabled, true)
+				},
+				expect: func(reg *driver.RegistryDefault) []settings.PostHookPostPersistExecutor {
+					return []settings.PostHookPostPersistExecutor{
+						hook.NewVerifier(reg),
+						hook.NewWebHook(reg, json.RawMessage(`{"body":"bar","method":"POST","url":"foo"}`)),
+					}
+				},
+			},
+			{
+				uc: "Two web_hooks are configured on a global level",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceSettingsAfter+".hooks", []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.PostHookPostPersistExecutor {
+					return []settings.PostHookPostPersistExecutor{
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"POST","url":"foo"}`)),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"bar"}`)),
+					}
+				},
+			},
+			{
+				uc: "Hooks are configured on a global level, as well as on a strategy level",
+				prep: func(conf *config.Config) {
+					conf.MustSet(config.ViperKeySelfServiceVerificationEnabled, true)
+					conf.MustSet(config.ViperKeySelfServiceSettingsAfter+".profile.hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "GET"}},
+					})
+					conf.MustSet(config.ViperKeySelfServiceSettingsAfter+".hooks", []map[string]interface{}{
+						{"hook": "web_hook", "config": map[string]interface{}{"url": "foo", "method": "POST"}},
+					})
+				},
+				expect: func(reg *driver.RegistryDefault) []settings.PostHookPostPersistExecutor {
+					return []settings.PostHookPostPersistExecutor{
+						hook.NewVerifier(reg),
+						hook.NewWebHook(reg, json.RawMessage(`{"method":"GET","url":"foo"}`)),
+					}
+				},
+			},
+		} {
+			t.Run(fmt.Sprintf("after/uc=%s", tc.uc), func(t *testing.T) {
+				conf, reg := internal.NewFastRegistryWithMocks(t)
+				tc.prep(conf)
+
+				h := reg.PostSettingsPostPersistHooks(ctx, "profile")
+
+				expectedExecutors := tc.expect(reg)
+				require.Len(t, h, len(expectedExecutors))
+				assert.Equal(t, expectedExecutors, h)
+			})
+		}
 	})
 }
 
diff --git a/internal/testhelpers/selfservice.go b/internal/testhelpers/selfservice.go
index f7ccfb112..cce5312c3 100644
--- a/internal/testhelpers/selfservice.go
+++ b/internal/testhelpers/selfservice.go
@@ -90,10 +90,15 @@ func SelfServiceHookFakeIdentity(t *testing.T) *identity.Identity {
 func SelfServiceHookConfigReset(t *testing.T, conf *config.Config) func() {
 	return func() {
 		conf.MustSet(config.ViperKeySelfServiceLoginAfter, nil)
+		conf.MustSet(config.ViperKeySelfServiceLoginAfter+".hooks", nil)
 		conf.MustSet(config.ViperKeySelfServiceLoginBeforeHooks, nil)
+		conf.MustSet(config.ViperKeySelfServiceRecoveryAfter, nil)
+		conf.MustSet(config.ViperKeySelfServiceRecoveryAfter+".hooks", nil)
 		conf.MustSet(config.ViperKeySelfServiceRegistrationAfter, nil)
+		conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+".hooks", nil)
 		conf.MustSet(config.ViperKeySelfServiceRegistrationBeforeHooks, nil)
 		conf.MustSet(config.ViperKeySelfServiceSettingsAfter, nil)
+		conf.MustSet(config.ViperKeySelfServiceSettingsAfter+".hooks", nil)
 	}
 }
 
@@ -130,18 +135,18 @@ func SelfServiceHookRegistrationViperSetPost(t *testing.T, conf *config.Config,
 }
 
 func SelfServiceHookLoginErrorHandler(t *testing.T, w http.ResponseWriter, r *http.Request, err error) bool {
-	return selfServiceHookErrorHandler(t, w, r, login.ErrHookAbortFlow, err)
+	return SelfServiceHookErrorHandler(t, w, r, login.ErrHookAbortFlow, err)
 }
 
 func SelfServiceHookRegistrationErrorHandler(t *testing.T, w http.ResponseWriter, r *http.Request, err error) bool {
-	return selfServiceHookErrorHandler(t, w, r, registration.ErrHookAbortFlow, err)
+	return SelfServiceHookErrorHandler(t, w, r, registration.ErrHookAbortFlow, err)
 }
 
 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.ErrHookAbortRequest, err)
 }
 
-func selfServiceHookErrorHandler(t *testing.T, w http.ResponseWriter, r *http.Request, abortErr error, actualErr error) bool {
+func SelfServiceHookErrorHandler(t *testing.T, w http.ResponseWriter, r *http.Request, abortErr error, actualErr error) bool {
 	if actualErr != nil {
 		t.Logf("received error: %+v", actualErr)
 		if errors.Is(actualErr, abortErr) {
@@ -155,26 +160,26 @@ func selfServiceHookErrorHandler(t *testing.T, w http.ResponseWriter, r *http.Re
 }
 
 func SelfServiceMakeLoginPreHookRequest(t *testing.T, ts *httptest.Server) (*http.Response, string) {
-	return selfServiceMakeHookRequest(t, ts, "/login/pre", false, url.Values{})
+	return SelfServiceMakeHookRequest(t, ts, "/login/pre", false, url.Values{})
 }
 
 func SelfServiceMakeLoginPostHookRequest(t *testing.T, ts *httptest.Server, asAPI bool, query url.Values) (*http.Response, string) {
-	return selfServiceMakeHookRequest(t, ts, "/login/post", asAPI, query)
+	return SelfServiceMakeHookRequest(t, ts, "/login/post", asAPI, query)
 }
 
 func SelfServiceMakeRegistrationPreHookRequest(t *testing.T, ts *httptest.Server) (*http.Response, string) {
-	return selfServiceMakeHookRequest(t, ts, "/registration/pre", false, url.Values{})
+	return SelfServiceMakeHookRequest(t, ts, "/registration/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)
+	return SelfServiceMakeHookRequest(t, ts, "/registration/post", asAPI, query)
 }
 
 func SelfServiceMakeSettingsPostHookRequest(t *testing.T, ts *httptest.Server, asAPI bool, query url.Values) (*http.Response, string) {
-	return selfServiceMakeHookRequest(t, ts, "/settings/post", asAPI, query)
+	return SelfServiceMakeHookRequest(t, ts, "/settings/post", asAPI, query)
 }
 
-func selfServiceMakeHookRequest(t *testing.T, ts *httptest.Server, suffix string, asAPI bool, query url.Values) (*http.Response, string) {
+func SelfServiceMakeHookRequest(t *testing.T, ts *httptest.Server, suffix string, asAPI bool, query url.Values) (*http.Response, string) {
 	if len(query) > 0 {
 		suffix += "?" + query.Encode()
 	}
diff --git a/selfservice/flow/recovery/hook_test.go b/selfservice/flow/recovery/hook_test.go
new file mode 100644
index 000000000..3c2c574d6
--- /dev/null
+++ b/selfservice/flow/recovery/hook_test.go
@@ -0,0 +1,84 @@
+package recovery_test
+
+import (
+	"context"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/ory/kratos/session"
+
+	"github.com/ory/kratos/selfservice/flow/recovery"
+
+	"github.com/gobuffalo/httptest"
+	"github.com/julienschmidt/httprouter"
+	"github.com/ory/kratos/driver/config"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/internal"
+	"github.com/ory/kratos/internal/testhelpers"
+	"github.com/ory/kratos/selfservice/flow"
+	"github.com/ory/kratos/x"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRecoveryExecutor(t *testing.T) {
+	conf, reg := internal.NewFastRegistryWithMocks(t)
+
+	newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
+		router := httprouter.New()
+
+		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)
+			require.NoError(t, err)
+			s := session.NewActiveSession(i, conf, time.Now().UTC())
+			a.RequestURL = x.RequestURL(r).String()
+			if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PostRecoveryHook(w, r, a, s)) {
+				_, _ = w.Write([]byte("ok"))
+			}
+		})
+
+		ts := httptest.NewServer(router)
+		t.Cleanup(ts.Close)
+		conf.MustSet(config.ViperKeyPublicBaseURL, ts.URL)
+		return ts
+	}
+
+	t.Run("method=PostRecoveryHook", func(t *testing.T) {
+		t.Run("case=pass without hooks", func(t *testing.T) {
+			t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
+			i := testhelpers.SelfServiceHookFakeIdentity(t)
+			ts := newServer(t, i, flow.TypeBrowser)
+
+			res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
+
+			assert.EqualValues(t, http.StatusOK, res.StatusCode)
+		})
+
+		t.Run("case=pass if hooks pass", func(t *testing.T) {
+			t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
+			conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal),
+				[]config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
+			i := testhelpers.SelfServiceHookFakeIdentity(t)
+			ts := newServer(t, i, flow.TypeBrowser)
+
+			res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
+
+			assert.EqualValues(t, http.StatusOK, res.StatusCode)
+		})
+
+		t.Run("case=fail if hooks fail", func(t *testing.T) {
+			t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
+			conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal),
+				[]config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "abort"}`)}})
+			i := testhelpers.SelfServiceHookFakeIdentity(t)
+			ts := newServer(t, i, flow.TypeBrowser)
+
+			res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
+
+			assert.EqualValues(t, http.StatusOK, res.StatusCode)
+			assert.Equal(t, "", body)
+		})
+	})
+}
diff --git a/selfservice/flow/verification/hook_test.go b/selfservice/flow/verification/hook_test.go
new file mode 100644
index 000000000..aa33f74de
--- /dev/null
+++ b/selfservice/flow/verification/hook_test.go
@@ -0,0 +1,81 @@
+package verification_test
+
+import (
+	"context"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/ory/kratos/selfservice/flow/verification"
+
+	"github.com/gobuffalo/httptest"
+	"github.com/julienschmidt/httprouter"
+	"github.com/ory/kratos/driver/config"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/internal"
+	"github.com/ory/kratos/internal/testhelpers"
+	"github.com/ory/kratos/selfservice/flow"
+	"github.com/ory/kratos/x"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestVerificationExecutor(t *testing.T) {
+	conf, reg := internal.NewFastRegistryWithMocks(t)
+
+	newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
+		router := httprouter.New()
+
+		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)
+			require.NoError(t, err)
+			a.RequestURL = x.RequestURL(r).String()
+			if testhelpers.SelfServiceHookErrorHandler(t, w, r, verification.ErrHookAbortFlow, reg.VerificationExecutor().PostVerificationHook(w, r, a, i)) {
+				_, _ = w.Write([]byte("ok"))
+			}
+		})
+
+		ts := httptest.NewServer(router)
+		t.Cleanup(ts.Close)
+		conf.MustSet(config.ViperKeyPublicBaseURL, ts.URL)
+		return ts
+	}
+
+	t.Run("method=PostVerificationHook", func(t *testing.T) {
+		t.Run("case=pass without hooks", func(t *testing.T) {
+			t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
+			i := testhelpers.SelfServiceHookFakeIdentity(t)
+			ts := newServer(t, i, flow.TypeBrowser)
+
+			res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/post", false, url.Values{})
+
+			assert.EqualValues(t, http.StatusOK, res.StatusCode)
+		})
+
+		t.Run("case=pass if hooks pass", func(t *testing.T) {
+			t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
+			conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceVerificationAfter, config.HookGlobal),
+				[]config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
+			i := testhelpers.SelfServiceHookFakeIdentity(t)
+			ts := newServer(t, i, flow.TypeBrowser)
+
+			res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/post", false, url.Values{})
+
+			assert.EqualValues(t, http.StatusOK, res.StatusCode)
+		})
+
+		t.Run("case=fail if hooks fail", func(t *testing.T) {
+			t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
+			conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceVerificationAfter, config.HookGlobal),
+				[]config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostVerificationHook": "abort"}`)}})
+			i := testhelpers.SelfServiceHookFakeIdentity(t)
+			ts := newServer(t, i, flow.TypeBrowser)
+
+			res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/post", false, url.Values{})
+
+			assert.EqualValues(t, http.StatusOK, res.StatusCode)
+			assert.Equal(t, "", body)
+		})
+	})
+}
diff --git a/selfservice/hook/stub/test_body.jsonnet b/selfservice/hook/stub/test_body.jsonnet
new file mode 100644
index 000000000..0b8d24119
--- /dev/null
+++ b/selfservice/hook/stub/test_body.jsonnet
@@ -0,0 +1,7 @@
+function(ctx) {
+  flow_id: ctx.flow.id,
+  identity_id: if ctx["identity"] != null then ctx.identity.id,
+  headers: ctx.request_headers,
+  url: ctx.request_url,
+  method: ctx.request_method
+}
diff --git a/selfservice/hook/web_hook_test.go b/selfservice/hook/web_hook_test.go
new file mode 100644
index 000000000..894afd32a
--- /dev/null
+++ b/selfservice/hook/web_hook_test.go
@@ -0,0 +1,564 @@
+package hook
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/ory/kratos/selfservice/flow/recovery"
+	"github.com/ory/kratos/selfservice/flow/registration"
+	"github.com/ory/kratos/selfservice/flow/settings"
+	"github.com/ory/kratos/selfservice/flow/verification"
+
+	"github.com/ory/kratos/selfservice/flow"
+
+	"github.com/julienschmidt/httprouter"
+	"github.com/ory/kratos/identity"
+	"github.com/ory/kratos/x"
+
+	"github.com/ory/kratos/session"
+
+	"github.com/ory/kratos/selfservice/flow/login"
+	"github.com/stretchr/testify/require"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNoopAuthStrategy(t *testing.T) {
+	req := http.Request{Header: map[string][]string{}}
+	auth := noopAuthStrategy{}
+
+	auth.apply(&req)
+
+	assert.Empty(t, req.Header, "Empty auth strategy shall not modify any request headers")
+}
+
+func TestBasicAuthStrategy(t *testing.T) {
+	req := http.Request{Header: map[string][]string{}}
+	auth := basicAuthStrategy{
+		user:     "test-user",
+		password: "test-pass",
+	}
+
+	auth.apply(&req)
+
+	assert.Len(t, req.Header, 1)
+
+	user, pass, _ := req.BasicAuth()
+	assert.Equal(t, "test-user", user)
+	assert.Equal(t, "test-pass", pass)
+}
+
+func TestApiKeyInHeaderStrategy(t *testing.T) {
+	req := http.Request{Header: map[string][]string{}}
+	auth := apiKeyStrategy{
+		in:    "header",
+		name:  "my-api-key-name",
+		value: "my-api-key-value",
+	}
+
+	auth.apply(&req)
+
+	require.Len(t, req.Header, 1)
+
+	actualValue := req.Header.Get("my-api-key-name")
+	assert.Equal(t, "my-api-key-value", actualValue)
+}
+
+func TestApiKeyInCookieStrategy(t *testing.T) {
+	req := http.Request{Header: map[string][]string{}}
+	auth := apiKeyStrategy{
+		in:    "cookie",
+		name:  "my-api-key-name",
+		value: "my-api-key-value",
+	}
+
+	auth.apply(&req)
+
+	cookies := req.Cookies()
+	assert.Len(t, cookies, 1)
+
+	assert.Equal(t, "my-api-key-name", cookies[0].Name)
+	assert.Equal(t, "my-api-key-value", cookies[0].Value)
+}
+
+func TestJsonNetSupport(t *testing.T) {
+	f := login.Flow{ID: x.NewUUID()}
+	i := identity.NewIdentity("")
+
+	headers := http.Header{}
+	headers.Add("Some-Header", "Some-Value")
+	headers.Add("Cookie", "c1=v1")
+	headers.Add("Cookie", "c2=v2")
+	data := &templateContext{
+		Flow:           &f,
+		RequestHeaders: headers,
+		RequestMethod:  "POST",
+		RequestUrl:     "https://test.kratos.ory.sh/some-test-path",
+		Identity:       i,
+	}
+
+	b, err := createBody("./stub/test_body.jsonnet", data)
+	assert.NoError(t, err)
+
+	buf := new(strings.Builder)
+	io.Copy(buf, b)
+
+	expected := fmt.Sprintf(`
+		{
+			"flow_id": "%s",
+			"identity_id": "%s",
+			"headers": {
+				"Cookie": ["%s", "%s"],
+				"Some-Header": ["%s"]
+			},
+			"method": "%s",
+			"url": "%s"
+		}`,
+		f.ID, i.ID,
+		data.RequestHeaders.Values("Cookie")[0],
+		data.RequestHeaders.Values("Cookie")[1],
+		data.RequestHeaders.Get("Some-Header"),
+		data.RequestMethod,
+		data.RequestUrl)
+
+	assert.JSONEq(t, expected, buf.String())
+}
+
+func TestWebHookConfig(t *testing.T) {
+	for _, tc := range []struct {
+		strategy     string
+		method       string
+		url          string
+		body         string
+		rawConfig    string
+		authStrategy AuthStrategy
+	}{
+		{
+			strategy: "empty",
+			method:   "POST",
+			url:      "https://test.kratos.ory.sh/my_hook1",
+			body:     "/path/to/my/jsonnet1.file",
+			rawConfig: `{
+				"url": "https://test.kratos.ory.sh/my_hook1",
+				"method": "POST",
+				"body": "/path/to/my/jsonnet1.file"
+			}`,
+			authStrategy: &noopAuthStrategy{},
+		},
+		{
+			strategy: "basic-auth",
+			method:   "GET",
+			url:      "https://test.kratos.ory.sh/my_hook2",
+			body:     "/path/to/my/jsonnet2.file",
+			rawConfig: `{
+				"url": "https://test.kratos.ory.sh/my_hook2",
+				"method": "GET",
+				"body": "/path/to/my/jsonnet2.file",
+				"auth": {
+					"type": "basic-auth",
+					"config": {
+						"user": "test-api-user",
+						"password": "secret"
+					}
+				}
+			}`,
+			authStrategy: &basicAuthStrategy{},
+		},
+		{
+			strategy: "api-key/header",
+			method:   "DELETE",
+			url:      "https://test.kratos.ory.sh/my_hook3",
+			body:     "/path/to/my/jsonnet3.file",
+			rawConfig: `{
+				"url": "https://test.kratos.ory.sh/my_hook3",
+				"method": "DELETE",
+				"body": "/path/to/my/jsonnet3.file",
+				"auth": {
+					"type": "api-key",
+					"config": {
+						"in": "header",
+						"name": "my-api-key",
+						"value": "secret"
+					}
+				}
+			}`,
+			authStrategy: &apiKeyStrategy{},
+		},
+		{
+			strategy: "api-key/cookie",
+			method:   "POST",
+			url:      "https://test.kratos.ory.sh/my_hook4",
+			body:     "/path/to/my/jsonnet4.file",
+			rawConfig: `{
+				"url": "https://test.kratos.ory.sh/my_hook4",
+				"method": "POST",
+				"body": "/path/to/my/jsonnet4.file",
+				"auth": {
+					"type": "api-key",
+					"config": {
+						"in": "cookie",
+						"name": "my-api-key",
+						"value": "secret"
+					}
+				}
+			}`,
+			authStrategy: &apiKeyStrategy{},
+		},
+	} {
+		t.Run("auth-strategy="+tc.strategy, func(t *testing.T) {
+			conf, err := newWebHookConfig([]byte(tc.rawConfig))
+			assert.Nil(t, err)
+
+			assert.Equal(t, tc.url, conf.url)
+			assert.Equal(t, tc.method, conf.method)
+			assert.Equal(t, tc.body, conf.templatePath)
+			assert.NotNil(t, conf.auth)
+			assert.IsTypef(t, tc.authStrategy, conf.auth, "Auth should be of the expected type")
+		})
+	}
+}
+
+func TestWebHooks(t *testing.T) {
+	type WebHookRequest struct {
+		Body    string
+		Headers http.Header
+		Method  string
+	}
+
+	webHookEndPoint := func(whr *WebHookRequest) httprouter.Handle {
+		return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
+			body, err := ioutil.ReadAll(r.Body)
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				return
+			}
+			whr.Body = string(body)
+			whr.Headers = r.Header
+			whr.Method = r.Method
+		}
+	}
+
+	webHookHttpCodeEndPoint := func(code int) httprouter.Handle {
+		return func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
+			w.WriteHeader(code)
+		}
+	}
+
+	path := "/web_hook"
+	newServer := func(f httprouter.Handle) *httptest.Server {
+		r := httprouter.New()
+
+		r.Handle("CONNECT", path, f)
+		r.DELETE(path, f)
+		r.GET(path, f)
+		r.OPTIONS(path, f)
+		r.PATCH(path, f)
+		r.POST(path, f)
+		r.PUT(path, f)
+		r.Handle("TRACE", path, f)
+
+		ts := httptest.NewServer(r)
+		t.Cleanup(ts.Close)
+		return ts
+	}
+
+	bodyWithFlowOnly := func(req *http.Request, f flow.Flow) string {
+		h, _ := json.Marshal(req.Header)
+		return fmt.Sprintf(`{
+   					"flow_id": "%s",
+					"identity_id": null,
+   					"headers": %s,
+					"method": "%s",
+					"url": "%s"
+				}`, f.GetID(), string(h), req.Method, req.RequestURI)
+	}
+
+	bodyWithFlowAndIdentity := func(req *http.Request, f flow.Flow, s *session.Session) string {
+		h, _ := json.Marshal(req.Header)
+		return fmt.Sprintf(`{
+   					"flow_id": "%s",
+					"identity_id": "%s",
+   					"headers": %s,
+					"method": "%s",
+					"url": "%s"
+				}`, f.GetID(), s.Identity.ID, string(h), req.Method, req.RequestURI)
+	}
+
+	for _, tc := range []struct {
+		uc           string
+		callWebHook  func(wh *WebHook, req *http.Request, f flow.Flow, s *session.Session) error
+		expectedBody func(req *http.Request, f flow.Flow, s *session.Session) string
+		createFlow   func() flow.Flow
+	}{
+		{
+			uc:         "Pre Login Hook",
+			createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID()} },
+			callWebHook: func(wh *WebHook, req *http.Request, f flow.Flow, _ *session.Session) error {
+				return wh.ExecuteLoginPreHook(nil, req, f.(*login.Flow))
+			},
+			expectedBody: func(req *http.Request, f flow.Flow, _ *session.Session) string {
+				return bodyWithFlowOnly(req, f)
+			},
+		},
+		{
+			uc:         "Post Login Hook",
+			createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID()} },
+			callWebHook: func(wh *WebHook, req *http.Request, f flow.Flow, s *session.Session) error {
+				return wh.ExecuteLoginPostHook(nil, req, f.(*login.Flow), s)
+			},
+			expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string {
+				return bodyWithFlowAndIdentity(req, f, s)
+			},
+		},
+		{
+			uc:         "Pre Registration Hook",
+			createFlow: func() flow.Flow { return &registration.Flow{ID: x.NewUUID()} },
+			callWebHook: func(wh *WebHook, req *http.Request, f flow.Flow, _ *session.Session) error {
+				return wh.ExecuteRegistrationPreHook(nil, req, f.(*registration.Flow))
+			},
+			expectedBody: func(req *http.Request, f flow.Flow, _ *session.Session) string {
+				return bodyWithFlowOnly(req, f)
+			},
+		},
+		{
+			uc:         "Post Registration Hook",
+			createFlow: func() flow.Flow { return &registration.Flow{ID: x.NewUUID()} },
+			callWebHook: func(wh *WebHook, req *http.Request, f flow.Flow, s *session.Session) error {
+				return wh.ExecutePostRegistrationPostPersistHook(nil, req, f.(*registration.Flow), s)
+			},
+			expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string {
+				return bodyWithFlowAndIdentity(req, f, s)
+			},
+		},
+		{
+			uc:         "Post Recovery Hook",
+			createFlow: func() flow.Flow { return &recovery.Flow{ID: x.NewUUID()} },
+			callWebHook: func(wh *WebHook, req *http.Request, f flow.Flow, s *session.Session) error {
+				return wh.ExecutePostRecoveryHook(nil, req, f.(*recovery.Flow), s)
+			},
+			expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string {
+				return bodyWithFlowAndIdentity(req, f, s)
+			},
+		},
+		{
+			uc:         "Post Verification Hook",
+			createFlow: func() flow.Flow { return &verification.Flow{ID: x.NewUUID()} },
+			callWebHook: func(wh *WebHook, req *http.Request, f flow.Flow, s *session.Session) error {
+				return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity)
+			},
+			expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string {
+				return bodyWithFlowAndIdentity(req, f, s)
+			},
+		},
+		{
+			uc:         "Post Settings Hook",
+			createFlow: func() flow.Flow { return &settings.Flow{ID: x.NewUUID()} },
+			callWebHook: func(wh *WebHook, req *http.Request, f flow.Flow, s *session.Session) error {
+				return wh.ExecuteSettingsPostPersistHook(nil, req, f.(*settings.Flow), s.Identity)
+			},
+			expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string {
+				return bodyWithFlowAndIdentity(req, f, s)
+			},
+		},
+	} {
+		t.Run("uc="+tc.uc, func(t *testing.T) {
+			for _, auth := range []struct {
+				uc               string
+				createAuthConfig func() string
+				expectedHeader   func(header http.Header)
+			}{
+				{
+					uc:               "no auth",
+					createAuthConfig: func() string { return "{}" },
+					expectedHeader:   func(header http.Header) {},
+				},
+				{
+					uc: "api key in header",
+					createAuthConfig: func() string {
+						return `{
+							"type": "api-key",
+							"config": {
+								"name": "My-Key",
+								"value": "My-Key-Value",
+								"in": "header"
+							}
+						}`
+					},
+					expectedHeader: func(header http.Header) {
+						header.Set("My-Key", "My-Key-Value")
+					},
+				},
+				{
+					uc: "api key in cookie",
+					createAuthConfig: func() string {
+						return `{
+							"type": "api-key",
+							"config": {
+								"name": "My-Key",
+								"value": "My-Key-Value",
+								"in": "cookie"
+							}
+						}`
+					},
+					expectedHeader: func(header http.Header) {
+						header.Set("Cookie", "My-Key=My-Key-Value")
+					},
+				},
+				{
+					uc: "basic auth",
+					createAuthConfig: func() string {
+						return `{
+							"type": "basic-auth",
+							"config": {
+								"user": "My-User",
+								"password": "Super-Secret"
+							}
+						}`
+					},
+					expectedHeader: func(header http.Header) {
+						header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("My-User:Super-Secret")))
+					},
+				},
+			} {
+				t.Run("auth="+auth.uc, func(t *testing.T) {
+					for _, method := range []string{"CONNECT", "DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT", "TRACE", "GARBAGE"} {
+						t.Run("method="+method, func(t *testing.T) {
+							f := tc.createFlow()
+							req := &http.Request{
+								Header:     map[string][]string{"Some-Header": {"Some-Value"}},
+								RequestURI: "https://www.ory.sh/some_end_point",
+								Method:     http.MethodPost,
+							}
+							s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}}
+							whr := &WebHookRequest{}
+							ts := newServer(webHookEndPoint(whr))
+							conf := json.RawMessage(fmt.Sprintf(`{
+								"url": "%s",
+								"method": "%s",
+								"body": "%s",
+								"auth": %s
+							}`, ts.URL+path, method, "./stub/test_body.jsonnet", auth.createAuthConfig()))
+							wh := NewWebHook(nil, conf)
+
+							err := tc.callWebHook(wh, req, f, s)
+							if method == "GARBAGE" {
+								assert.Error(t, err)
+								return
+							}
+
+							assert.NoError(t, err)
+
+							assert.Equal(t, method, whr.Method)
+
+							expectedHeader := http.Header{}
+							expectedHeader.Set("Content-Type", "application/json")
+							auth.expectedHeader(expectedHeader)
+							for k, v := range expectedHeader {
+								vals := whr.Headers.Values(k)
+								assert.Equal(t, v, vals)
+							}
+
+							if method != "TRACE" {
+								// According to the HTTP spec any request method, but TRACE is allowed to
+								// have a body. Even this is a really bad practice for some of them, like for
+								// GET
+								assert.JSONEq(t, tc.expectedBody(req, f, s), whr.Body)
+							} else {
+								assert.Emptyf(t, whr.Body, "HTTP %s is not allowed to have a body", method)
+							}
+						})
+					}
+				})
+			}
+		})
+	}
+
+	t.Run("Must error when config is erroneous", func(t *testing.T) {
+		req := &http.Request{
+			Header:     map[string][]string{"Some-Header": {"Some-Value"}},
+			RequestURI: "https://www.ory.sh/some_end_point",
+			Method:     http.MethodPost,
+		}
+		f := &login.Flow{ID: x.NewUUID()}
+		conf := json.RawMessage(fmt.Sprintf(`{
+					"url": "/foo",
+					"method": "BAR"
+				`)) // closing } is missing
+		wh := NewWebHook(nil, conf)
+
+		err := wh.ExecuteLoginPreHook(nil, req, f)
+		assert.Error(t, err)
+	})
+
+	t.Run("Must error when template is erroneous", func(t *testing.T) {
+		ts := newServer(webHookHttpCodeEndPoint(200))
+		req := &http.Request{
+			Header:     map[string][]string{"Some-Header": {"Some-Value"}},
+			RequestURI: "https://www.ory.sh/some_end_point",
+			Method:     http.MethodPost,
+		}
+		f := &login.Flow{ID: x.NewUUID()}
+		conf := json.RawMessage(fmt.Sprintf(`{
+					"url": "%s",
+					"method": "%s",
+					"body": "%s"
+				}`, ts.URL+path, "POST", "./stub/bad_template.jsonnet"))
+		wh := NewWebHook(nil, conf)
+
+		err := wh.ExecuteLoginPreHook(nil, req, f)
+		assert.Error(t, err)
+	})
+
+	boolToString := func(f bool) string {
+		if f {
+			return " not"
+		} else {
+			return ""
+		}
+	}
+
+	for _, tc := range []struct {
+		code        int
+		mustSuccess bool
+	}{
+		{200, true},
+		{299, true},
+		{300, true},
+		{399, true},
+		{400, false},
+		{499, false},
+		{500, false},
+		{599, false},
+	} {
+		t.Run("Must"+boolToString(tc.mustSuccess)+" error when end point is returning "+strconv.Itoa(tc.code), func(t *testing.T) {
+			ts := newServer(webHookHttpCodeEndPoint(tc.code))
+			req := &http.Request{
+				Header:     map[string][]string{"Some-Header": {"Some-Value"}},
+				RequestURI: "https://www.ory.sh/some_end_point",
+				Method:     http.MethodPost,
+			}
+			f := &login.Flow{ID: x.NewUUID()}
+			conf := json.RawMessage(fmt.Sprintf(`{
+					"url": "%s",
+					"method": "%s",
+					"body": "%s"
+				}`, ts.URL+path, "POST", "./stub/test_body.jsonnet"))
+			wh := NewWebHook(nil, conf)
+
+			err := wh.ExecuteLoginPreHook(nil, req, f)
+			if tc.mustSuccess {
+				assert.NoError(t, err)
+			} else {
+				assert.Error(t, err)
+			}
+		})
+	}
+}
diff --git a/test/schema/fixtures/config.schema.test.success/root.full.yml b/test/schema/fixtures/config.schema.test.success/root.full.yml
index 8da0423f1..1d447700c 100644
--- a/test/schema/fixtures/config.schema.test.success/root.full.yml
+++ b/test/schema/fixtures/config.schema.test.success/root.full.yml
@@ -30,15 +30,13 @@ selfservice:
       enabled: true
       ui_url: /verification
       lifespan: 1h
-      after:
-        default_browser_return_url: "#/definitions/defaultReturnTo"
+      after: "#/definitions/selfServiceAfterVerification"
 
     recovery:
       enabled: true
       ui_url: /recovery
       lifespan: 1h
-      after:
-        default_browser_return_url: "#/definitions/defaultReturnTo"
+      after: "#/definitions/selfServiceAfterRecovery"
 
     error:
       ui_url: /error