Skip to content
Back to skills

EmailOtpLoginMcp

ASecurity

Turn on passwordless login by a one-time code emailed to the user, on a Phase Two hosted Keycloak, driven through the Keycloak MCP server. Use this for "email OTP", "email a one-time code", "6-digit login code by email", or "passwordless login with a code instead of a link". Covers authoring the flow with importAuthenticationFlow (none ships auto-created), the load-bearing identifier-step choice that decides whether the realm leaks which addresses have accounts, setSmtpSettings, and setBruteF...

  • 4 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 15, 2026
ai-agentsrustgotestinggitapidocumentation

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 15, 2026

npx -y skills add p2-inc/keycloak-skills --skill emailOtpLoginMcp --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of EmailOtpLoginMcp?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for EmailOtpLoginMcp
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/p2-inc-emailotploginmcp/badge)](https://www.skillsdirectory.com/skills/p2-inc-emailotploginmcp)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: emailOtpLoginMcp
description: >-
  Turn on passwordless login by a one-time code emailed to the user, on a Phase Two hosted
  Keycloak, driven through the Keycloak MCP server. Use this for "email OTP", "email a
  one-time code", "6-digit login code by email", or "passwordless login with a code
  instead of a link". Covers authoring the flow with importAuthenticationFlow (none ships
  auto-created), the load-bearing identifier-step choice that decides whether the realm
  leaks which addresses have accounts, setSmtpSettings, and setBruteForceProtection. Not
  Keycloak's TOTP/HOTP authenticator apps, and not magic link.
---

<!-- Copyright 2026 Phase Two, Inc. -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->

# emailOtpLoginMcp

## Step 0 — Is the MCP surface actually here?

**Do this before any tool call.** Check your tool list once, now, for:

`bindClientAuthenticationFlow` · `bindRealmAuthenticationFlow` · `setExecutionAuthenticatorConfig` · `setSmtpSettings` · `whoAmI`

- **All present** → say "Using the Keycloak MCP tools", then follow this skill as written.
- **Any missing** → say "Keycloak MCP tools unavailable in this session (missing: …) — using the
  Keycloak admin REST API instead", then **keep reading**. Every stage below still applies: the
  behaviour it configures is a property of Keycloak, not of the transport. Only the calls change —
  carry out each stage against the admin REST API (`$KC_URL/admin/realms/{realm}/...`) with an
  admin token. If the sibling skill `emailOtpLoginRest` is loaded, use its exact REST recipes.

Decide once, here. Do not call an MCP tool to discover whether it exists, and do not alternate
between the two paths afterwards — discovering the gap a step at a time is measurably more
expensive than committing up front.

Say which path you took either way: a run that silently swaps tooling is indistinguishable from
one that never had the tools.

## What this is, and what it isn't

The user types an identifier, receives a **6-digit numeric code by email**, and types it in to
finish logging in. No password, ever. This is the sibling mechanism
`admin-passwordless-magic-link-mcp.md` explicitly excludes ("a one-time code typed in — a sibling
authenticator, not covered here"):

| | Magic link | Email OTP (this doc) |
|---|---|---|
| What arrives | A signed action-token **link** to click | A **6-digit code** to type |
| Authenticator | `ext-magic-form` | `ext-email-otp` |
| Built-in flow ships with the provider? | Yes (`magic link`, auto-created) | **No — must be authored** |

Both come from the same `keycloak-magic-link` extension and both need realm SMTP.

## Tools this skill drives (Keycloak MCP server)

| Purpose | Tool |
|---|---|
| Identify caller / realm | `whoAmI` |
| Confirm no email-OTP flow already exists | `listAuthenticationFlows` |
| Author the flow **and bind it**, in one call | `importAuthenticationFlow` (needs the atomic-flows extension) |
| Inspect / adjust the `ext-email-otp` step's config | `listFlowExecutions` / `setExecutionAuthenticatorConfig` |
| Configure outgoing mail | `setSmtpSettings` |
| Turn on brute-force protection (see Stage 5 — it matters here) | `setBruteForceProtection` |
| Bind an already-existing flow | `bindRealmAuthenticationFlow` / `bindClientAuthenticationFlow` |
| Confirm bindings | `getAuthenticationBindings` |
| Look a user up when testing | `findUser` |

## Prerequisite: the keycloak-magic-link extension

`ext-email-otp` comes from **[p2-inc `keycloak-magic-link`](https://github.com/p2-inc/keycloak-magic-link)**
— the same jar that provides `ext-magic-form` — not from stock Keycloak. Phase Two's own
distribution bundles it. Keycloak's built-in OTP authenticators are TOTP/HOTP against an
authenticator app, which is a different mechanism requiring device enrollment; there is no stock
equivalent to email OTP. If the authenticator isn't available on the target, say so and stop
rather than substituting TOTP.

## Stage 1 — Establish identity and target realm

Call `whoAmI`; tell the user who they are and which realm the token came from. Confirm the target
deployment/realm and which client should use email OTP.

## Stage 2 — Author the flow (no built-in one exists)

Call `listAuthenticationFlows` first. Unlike magic-link — where the provider auto-creates a
`magic link` flow on every realm — **nothing auto-creates an email-OTP flow**, so expect to author
one.

Call `importAuthenticationFlow` with the arrays from
[`../assets/email-otp-flow.partial-import.json`](../assets/email-otp-flow.partial-import.json),
plus the binding in the same payload:

```json
{
  "authenticationFlows": [ /* from the asset */ ],
  "authenticatorConfig": [ /* from the asset */ ],
  "browserFlowBinding": "email-otp-flow"
}
```

For one client instead of realm-wide, use `clientFlowBinding: {clientId, browserFlowBinding}`.
The extension hash-prefixes the alias it creates, so read the real one back from the response
rather than assuming. A 404 means the atomic-flows extension isn't installed — offer it, and fall
back to `admin-email-otp-login.md`'s manual REST sequence only if the user declines.

**The execution order inside the forms sub-flow is load-bearing, and so is WHICH authenticator
collects the identifier** — verify both if authoring by hand rather than from the asset:

| Execution | Requirement | Why this authenticator, and why this position |
|---|---|---|
| `ext-auth-username-auth-note` | REQUIRED | `ext-email-otp` reads the *attempted username* off the auth session rather than collecting an address itself, so an identifier step must run first. Use this authenticator specifically (p2-inc `keycloak-orgs`), **not** stock `auth-username-form` — see Stage 3's anti-enumeration note for why the choice is load-bearing, not cosmetic. |
| `ext-email-otp` | REQUIRED | Generates the code, mails it, renders the code form, verifies the input. |

Putting `ext-email-otp` first, or omitting the identifier step entirely, yields a silent no-email
outcome rather than an error.

## Stage 3 — The identifier-step choice decides anti-enumeration, and the one config option

**Verified empirically, not just from source — this is the trap that survives even a correct
reading of the authenticator code**, because the leak comes from a different authenticator
entirely:

- Identifier step = stock `auth-username-form` → an unregistered address gets
  **"Invalid username or email."** on the spot, before `ext-email-otp` ever runs. Real
  enumeration, regardless of `ext-email-otp`'s own config.
- Identifier step = `ext-auth-username-auth-note` (Stage 2's choice) → an unregistered address
  reaches `ext-email-otp`, whose null-user handling *is* anti-enumeration-safe with force-create
  off: identical code-form response, no email, no error.

So "unregistered address gets an identical response" only holds for the specific authenticator
combination this asset uses — don't assume it for any email-OTP flow you didn't author this way.

`ext-email-otp` has exactly **one** configurable property beyond the identifier-step choice
above, and it is the same key magic-link uses (a shared constant in the extension), which is why
it reads oddly here: `ext-magic-create-nonexistent-user`.

**Its default differs from magic-link's, in the safe direction.** Verified in the extension's
source: `ext-magic-form` defaults it to `true` (silently provisioning an account for any address
typed — the trap the magic-link references warn about), while `ext-email-otp` defaults it to
**`false`**. So there is nothing to remember to turn off here; the asset sets it explicitly as
documentation. Set it to `"true"` only if self-registration by OTP is genuinely wanted (and note
that also reopens the enumeration question, since a match will now always exist).

To change it later, use `setExecutionAuthenticatorConfig` on the `ext-email-otp` execution — find
it with `listFlowExecutions` first.

Nothing else is tunable. The code is always a 6-digit numeric string, with no configurable
length, alphabet, or separate TTL.

## Stage 4 — Realm mail settings

`setSmtpSettings`. Same silent-failure trap as magic-link: a realm ships with no SMTP settings and
a failed send is swallowed internally, so an unconfigured realm looks identical to a working one
from the caller's side. Verify by checking what actually left (a capture point in test contexts),
not by trusting a success response.

## Stage 5 — Turn on brute-force protection (specific to this mechanism)

A wrong code is raised as an `INVALID_CREDENTIALS` failure challenge, so the realm's brute-force
settings are what actually govern guessing. Realms ship with brute-force **off**, and a 6-digit
code with unlimited attempts is weak — call `setBruteForceProtection` when standing this up, and
ask whether lockout should be temporary (auto-recovering) or permanent.

## Stage 6 — Bind it

| Surface | Tool |
|---|---|
| Realm-wide | `bindRealmAuthenticationFlow(bindingType="browser", flowAlias=...)` |
| Single client | `bindClientAuthenticationFlow(clientId=..., bindingType="browser", flowAlias=...)` |

Skip this if you already bound in the import payload. Ask which the user wants — realm-wide
affects every client including the admin and account consoles. Verify with
`getAuthenticationBindings`, passing the `clientId` when the binding was client-level (a
client override does not change the realm's `browserFlow`, so the realm-level values alone will
still show the untouched default).

## Stage 7 — Behaviors worth knowing before you're asked

Verified empirically against a live deployment, not just from source:

- **Anti-enumeration depends on Stage 2's identifier-step choice.** With
  `ext-auth-username-auth-note`, an unregistered address gets the identical code-form response —
  no email, no error, and "I never got a code" becomes indistinguishable from "that address has
  no account," by design.
- **New users need a name on file.** A user with no `firstName`/`lastName` can pick up a
  `VERIFY_PROFILE` required action, interrupting the flow right after a correct code with an
  unrelated profile screen. Not specific to email OTP, but easy to mistake for a flow bug while
  testing.
- **Refreshing does not resend.** The code lives in an auth-session note and the send is skipped
  whenever that note is set.
- **There is a built-in resend** (a `resend` form parameter clears the note and issues a fresh
  code); the bundled `otp-form.ftl` exposes it.
- **No independent expiry.** The code has no TTL of its own — it lives as long as the
  authentication session. Shorten the realm's login timeouts for a tighter window.
- **Success marks the email verified** (`emailVerified=true`), since possession was just proven.
  Useful, but it does mutate the user.

## Stage 8 — Verify by logging in, not by reading configuration

1. A **registered** address: identifier form → code form → the emailed 6-digit code completes
   login with an authorization code and the original `state`.
2. A **wrong** code: rejected with an invalid-code message (and counted toward brute force).
3. An **unregistered** address: the code form still appears (given `ext-auth-username-auth-note`
   as the identifier step), but confirm **no mail was sent** and **no user was created**.

## Common errors

- **Code form appears, no email ever arrives** — realm SMTP unconfigured or wrong (Stage 4); the
  failure is swallowed.
- **Unregistered address gets "Invalid username or email" instead of the code form** — the
  identifier step is stock `auth-username-form`; swap it for `ext-auth-username-auth-note`.
- **Nothing to send to** — the identifier step is missing or ordered after `ext-email-otp`
  (Stage 2).
- **Unexpected new accounts** — `ext-magic-create-nonexistent-user` was set to `"true"`.
- **A fresh test user hits an unrelated profile-completion screen right after the correct code** —
  `VERIFY_PROFILE` required action from a missing name on the user, not a flow bug.
- **Codes guessable indefinitely** — brute-force protection still off (Stage 5).
- **`ext-email-otp` unavailable** — the `keycloak-magic-link` extension isn't installed; report it
  rather than substituting Keycloak's TOTP.
- **A required MCP tool is missing** — report it and stop; do not switch to REST unsolicited.

Files in this skill

  • SKILL.md12.1 KB
  • assets/email-otp-flow.partial-import.json1.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.