Enforcing OIDC for some users with Ory Kratos Webhooks

Use webhooks to enforce authentication requirements for different users of your platform.

TISM on stage at Beer and BBQ Fest 2026

Ory Kratos is an identity provider and authentication service for use in your own platforms and apps. Out of the box it has support for username and password, social login services, and any OIDC compliant identity provider. This provides great flexiblity, you can mix and match IdPs for your users in any combination that makes sense.

You might want to force some users or user groups to use a specific OIDC provider. For example, if your app has a blend of staff accounts and general public, you probably want to force them to use your Single Sign-On provider, while letting your public users sign in with password or social sign in. If you are using Ory Network, then they cover this requirement with their B2B feature. However, is only available on their Growth and Enterprise plans. If you are self hosting or using the Production plan then you have to do more work yourself.

Flow Interrupting, Post-Login Webhook

To enforce login requirements we can implement a flow interrupting, post-login webhook. The webhook is called after the login flow has completed, but before the session is issued to the user. This allows us to decide whether the session should be issued.

Post Login Webhook Payload

Ory's docs are infuriating, and they don't actually list anywhere the data that can be sent to the webhook. You can discover this yourself, by using a jsonnet snipped that sends the full context:

function(ctx) {
    ctx: ctx
}

You can send the full ctx to your webhook, but you probably shouldn't

If you do this, your webhook will receive:

  • The current login flow
  • The complete Identity for the current user
  • The user's Session
  • Request headers
  • Request cookies

The full payload looks like this:

{
  "flow": {
    // the full Kratos Login Flow 
  },
  "identity": {
    "created_at": "2025-11-26T00:43:13.323402Z",
    "id": "0aa9a049-6826-4035-9f05-32137b0ca01b",
    "metadata_public": {},
    "organization_id": null,
    "recovery_addresses": [
      {
        "created_at": "2025-11-26T00:43:13.323586Z",
        "id": "ea073e9e-fcd2-4833-82f0-da524ed1174e",
        "updated_at": "2025-11-26T00:43:13.323586Z",
        "value": "user@example.com",
        "via": "email"
      }
    ],
    "schema_id": "default",
    "schema_url": "http://127.0.0.1:4433/schemas/ZGVmYXVsdA",
    "state": "active",
    "state_changed_at": "2025-11-26T00:43:13.322923Z",
    "traits": {
      "email": "user@example.com",
      "name": {
        "first": "Reba",
        "last": "Zachariah"
      }
    },
    "updated_at": "2025-11-26T00:43:13.323402Z",
    "verifiable_addresses": [
      {
        "created_at": "2025-11-26T00:43:13.323497Z",
        "id": "20072ac4-ca70-4529-87a7-87346beefb27",
        "status": "completed",
        "updated_at": "2025-11-26T00:43:13.323497Z",
        "value": "user@example.com",
        "verified": true,
        "verified_at": "2025-11-26T00:43:13.322Z",
        "via": "email"
      }
    ]
  },
  "request_cookies": {
    // key value pairs of all  the request cookies from the client
  },
  "request_headers": {
    // key value pairs of all  the request headers from the client
  },
  "request_method": "POST",
  "request_url": "http://127.0.0.1:4433/self-service/login?flow=9082155b-8c66-4a9b-a0ca-381adf850ecf",
  "session": {
    "active": true,
    "authenticated_at": "2026-08-12T02:28:58.592473547Z",
    "authentication_methods": [
      {
        "aal": "aal1",
        "completed_at": "2026-08-12T02:28:58.592404588Z",
        "method": "password"
      }
    ],
    "authenticator_assurance_level": "aal1",
    "devices": [
      {
        "id": "00000000-0000-0000-0000-000000000000",
        "ip_address": "127.0.0.1:58308",
        "location": "",
        "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
      }
    ],
    "expires_at": "2026-09-09T02:28:58.592473547Z",
    "id": "00000000-0000-0000-0000-000000000000",
    "identity": {
      "created_at": "2025-11-26T00:43:13.323402Z",
      "id": "0aa9a049-6826-4035-9f05-32137b0ca01b",
      "metadata_public": {},
      "organization_id": null,
      "recovery_addresses": [
        {
          "created_at": "2025-11-26T00:43:13.323586Z",
          "id": "ea073e9e-fcd2-4833-82f0-da524ed1174e",
          "updated_at": "2025-11-26T00:43:13.323586Z",
          "value": "user@example.com",
          "via": "email"
        }
      ],
      "schema_id": "default",
      "schema_url": "http://127.0.0.1:4433/schemas/ZGVmYXVsdA",
      "state": "active",
      "state_changed_at": "2025-11-26T00:43:13.322923Z",
      "traits": {
        "email": "user@example.com",
        "name": {
          "first": "Reba",
          "last": "Zachariah"
        }
      },
      "updated_at": "2025-11-26T00:43:13.323402Z",
      "verifiable_addresses": [
        {
          "created_at": "2025-11-26T00:43:13.323497Z",
          "id": "20072ac4-ca70-4529-87a7-87346beefb27",
          "status": "completed",
          "updated_at": "2025-11-26T00:43:13.323497Z",
          "value": "user@example.com",
          "verified": true,
          "verified_at": "2025-11-26T00:43:13.322Z",
          "via": "email"
        }
      ]
    },
    "issued_at": "2026-08-12T02:28:58.592473547Z"
  }
}

The full data available to the post login webhook

You probably don't want to send all of this to your webhook, though, so construct a jsonnet snippet that makes sense,

Check The User's Session

The webhook itself is quite simple. It should look in the session object's authentication_methods list. If a user has logged in with a password, you'll see something like this:

      {
        "aal": "aal1",
        "completed_at": "2026-08-12T02:28:58.592404588Z",
        "method": "password"
      }

Example password authentication method

If the user has logged in with OIDC, you'll see this:

      {
        "aal": "aal1",
        "completed_at": "2026-08-12T02:28:58.592404588Z",
        "method": "oidc",
        "provider": "provider_id"
      }

Example OIDC authentication method

Note that this array may have more than one element, and if the user has 2FA then you'll see that as an additional method.

Allow or Deny the Login

You can use this information to decide whether to allow or deny the login.

if (allowed(authentication_methods)) {
  // an empty object for Kratos to parse, but not making any changes
  return {};
}
else {
  // return a 403 to prevent the login
  res.status(403);
}

Configure Kratos

Once your webhook is done, you need to tell Kratos to use it by adding to the Kratos config file:

  flows:
    login:
      after:
        hooks:
          - hook: web_hook
            config:
              url: http://example.com/webhook
              body: base64://ZnVuY3Rpb24oY3R4KSB7CiAgICBpZGVudGl0eTogY3R4LmlkZW50aXR5LAogICAgc2Vzc2lvbjogY3R4LnNlc3Npb24KfQo=
              method: POST
              emit_analytics_event: true
              response:
                ignore: false
                parse: true
              auth:
                type: api_key
                config:
                  name: Authorization
                  value: KRATOS_WEBHOOKS
                  in: header

Example Kratos config

Conclusion

You can use anything about the identity or session to allow or deny the login. For example, specific email domains require a specific OIDC provider. You could enforce 2FA for some users but not others.

Webhook delivery needs to be reliable. As this is a flow-interrupting webhook, if Kratos can't reach the webhook then the user can't log in. You probably also need to implement something similar to enforce the same rules across registration and recovery flows to ensure consistent application of rules.

Have fun!