Skip to content
Back to skills

PasswordEmailOtpMfaRest

ASecurity

Harden existing password login with a one-time code emailed as a SECOND factor, on a self-managed Keycloak running the p2-inc keycloak-magic-link extension, using the Admin REST API only. Use this for "2FA by email", "MFA by email", "require a code after the password", or "email-based two-factor". The password stays required and gates the code - a wrong password must never send mail. Not passwordless email OTP (a different flow that removes the password), and not WebAuthn or TOTP as a second ...

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

Works with

  • cli
  • api

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 passwordEmailOtpMfaRest --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of PasswordEmailOtpMfaRest?

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

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

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: passwordEmailOtpMfaRest
description: >-
  Harden existing password login with a one-time code emailed as a SECOND factor, on a
  self-managed Keycloak running the p2-inc keycloak-magic-link extension, using the Admin
  REST API only. Use this for "2FA by email", "MFA by email", "require a code after the
  password", or "email-based two-factor". The password stays required and gates the code -
  a wrong password must never send mail. Not passwordless email OTP (a different flow that
  removes the password), and not WebAuthn or TOTP as a second factor.
---

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

# passwordEmailOtpMfaRest

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

Password stays. A correct password is **necessary but not sufficient** — after it, the user must
also enter a 6-digit code emailed to their account. This is **not** passwordless: it is
traditional login hardened with a second factor, and it uses a *different* first step than
`admin-email-otp-login.md`'s fully passwordless flow:

| | `admin:email-otp-login` (passwordless) | This doc (password + OTP, two-factor) |
|---|---|---|
| First step | `ext-auth-username-auth-note` (identifier only, no password) | `auth-username-password-form` (stock Keycloak — validates the password) |
| Getting the emailed code requires... | Just typing a known email address | Knowing the account's **password** |
| Security model | Possession of the inbox is the only factor | Password (something you know) + inbox (something you have) |

Both use `ext-email-otp` as the second execution and both need realm SMTP; only the first step
differs, and that difference is the entire point of this flow.

## Prerequisite: the keycloak-magic-link extension

Same as email-otp: `ext-email-otp` comes from
**[p2-inc `keycloak-magic-link`](https://github.com/p2-inc/keycloak-magic-link)**, not stock
Keycloak.

```bash
BASE=http://localhost:8080/auth
H="Authorization: Bearer $ADMIN_TOKEN"
REALM=<realm>

curl -s "$BASE/admin/realms/$REALM/authentication/authenticator-providers" -H "$H" \
  | jq -r '.[] | select(.id=="ext-email-otp")'
```

Empty output means it isn't installed — say so and stop.

## Author the flow

The shape, from
[`../assets/username-password-email-otp-flow.partial-import.json`](../assets/username-password-email-otp-flow.partial-import.json)
— **verified live**, including the negative cases, not just read from source:

| Sub-flow | Execution | Requirement | Why it's here |
|---|---|---|---|
| top-level | `auth-cookie` | ALTERNATIVE | An existing SSO session still works. |
| top-level | `identity-provider-redirector` | ALTERNATIVE | A federated path bypasses the forms sub-flow. |
| top-level | *forms sub-flow* | ALTERNATIVE | Taken when neither of the above applies. |
| forms | `auth-username-password-form` | REQUIRED | **The gate.** Stock Keycloak; validates the password before anything else runs. |
| forms | `ext-email-otp` | REQUIRED | Only reached after a correct password. Generates the code, mails it, verifies the input. |

This is the one place where `auth-username-password-form` is the *right* identifier-collection
step — the opposite of `admin-email-otp-login.md`'s guidance, where the same authenticator would
be wrong. There, the goal is a passwordless flow and `auth-username-password-form` would demand a
password that shouldn't exist as a requirement; here, demanding the password **is** the point.

Confirmed by driving real logins against a live deployment:

| Attempt | Result |
|---|---|
| Correct username + correct password | Reaches the OTP form; a real code is emailed |
| Correct username + **wrong** password | Rejected at the password step with `"Invalid username or password."` — **the OTP step is never reached and no mail is sent** |
| Unknown username | Same generic `"Invalid username or password."` — Keycloak's standard behavior for the combined username+password form gives no separate signal for "no such user" vs. "wrong password" |

The middle row is what actually proves this is two-factor rather than "email OTP with a password
field bolted on for show": knowing the account's email address is worthless without the password,
because the code is never sent until the password step succeeds.

Authoring paths, same two-plus-one-that-doesn't-work as the passwordless variant:

| Path | Cost | Requires |
|---|---|---|
| `POST /admin/realms/{realm}/authentication-flow/import?force={bool}` — authors and binds in one call | **One call** | The [p2-inc keycloak-atomic-auth-flows](https://github.com/p2-inc/keycloak-atomic-auth-flows) extension |
| Manual sequence: create flow → sub-flow → add each execution → set requirements → attach config → bind | Many calls | Nothing beyond stock Admin REST |
| ~~`POST /admin/realms/{realm}/partialImport`~~ | — | **Does not work** for authentication flows: HTTP 200, `added: 0`, nothing created. |

```bash
FLOW='email-otp-flow'
SUB='Email OTP forms'

curl -s -X POST "$BASE/admin/realms/$REALM/authentication/flows" -H "$H" \
  -H 'Content-Type: application/json' \
  -d "{\"alias\":\"$FLOW\",\"providerId\":\"basic-flow\",\"topLevel\":true,\"builtIn\":false}"

curl -s -X POST "$BASE/admin/realms/$REALM/authentication/flows/$(jq -rn --arg f "$FLOW" '$f|@uri')/executions/flow" \
  -H "$H" -H 'Content-Type: application/json' \
  -d "{\"alias\":\"$SUB\",\"provider\":\"basic-flow\",\"type\":\"basic-flow\"}"

for p in auth-cookie identity-provider-redirector; do
  curl -s -X POST "$BASE/admin/realms/$REALM/authentication/flows/$(jq -rn --arg f "$FLOW" '$f|@uri')/executions/execution" \
    -H "$H" -H 'Content-Type: application/json' -d "{\"provider\":\"$p\"}"
done
for p in auth-username-password-form ext-email-otp; do
  curl -s -X POST "$BASE/admin/realms/$REALM/authentication/flows/$(jq -rn --arg f "$SUB" '$f|@uri')/executions/execution" \
    -H "$H" -H 'Content-Type: application/json' -d "{\"provider\":\"$p\"}"
done
# New executions start DISABLED, and the add calls ignore a "requirement" field. Set each
# one with PUT on the flow's executions collection, sending the execution's WHOLE row back
# with only the requirement changed: the PUT also writes "priority", so a body of just
# {id, requirement} resets it to 0 (see flow-execution-order.md). A sub-flow's own row has
# no providerId; it is matched by its alias (displayName).
set_req() {  # <flow alias> <providerId or sub-flow alias> <requirement>
  F=$(jq -rn --arg f "$1" '$f|@uri')
  curl -s "$BASE/admin/realms/$REALM/authentication/flows/$F/executions" -H "$H" \
    | jq -c --arg k "$2" --arg r "$3" '.[] | select((.providerId // .displayName) == $k) | .requirement = $r' \
    | curl -s -o /dev/null -w "$2 -> $3: %{http_code}\n" -X PUT \
        "$BASE/admin/realms/$REALM/authentication/flows/$F/executions" -H "$H" \
        -H 'Content-Type: application/json' --data-binary @-
}
set_req "$FLOW" auth-cookie ALTERNATIVE
set_req "$FLOW" identity-provider-redirector ALTERNATIVE
set_req "$FLOW" "$SUB" ALTERNATIVE
set_req "$SUB" auth-username-password-form REQUIRED
set_req "$SUB" ext-email-otp REQUIRED
# Each PUT answers 204.

# Attach the config (next section) to the ext-email-otp execution.
EID=$(curl -s "$BASE/admin/realms/$REALM/authentication/flows/$(jq -rn --arg f "$SUB" '$f|@uri')/executions" -H "$H" \
  | jq -r '.[] | select(.providerId == "ext-email-otp") | .id')
curl -s -o /dev/null -w 'config: %{http_code}\n' -X POST "$BASE/admin/realms/$REALM/authentication/executions/$EID/config" \
  -H "$H" -H 'Content-Type: application/json' \
  -d '{"alias":"email-otp-config","config":{"ext-magic-create-nonexistent-user":"false"}}'
# 201 = attached.
```

## The one config option

Same shared key as plain email OTP:

```json
{ "alias": "email-otp-config", "config": { "ext-magic-create-nonexistent-user": "false" } }
```

It matters less here than in the passwordless case (a user has to already have a password to
reach this step at all, so force-create rarely has anything to do), but leave it at its safe
default unless there's a specific reason for this flow to create accounts.

## Realm mail settings

Same trap as every other magic-link-family flow: no SMTP configured by default, and a failed
send is swallowed silently. Verify by checking what actually left, not by trusting a `204`.

```bash
# Read-modify-write: a PUT that omits fields resets them. With auth "true", add user/password.
curl -s "$BASE/admin/realms/$REALM" -H "$H" \
  | jq '.smtpServer = {host: "<smtp-host>", port: "<smtp-port>", from: "<from-address>",
                       auth: "false", starttls: "false", ssl: "false"}' \
  | curl -s -o /dev/null -w 'smtp: %{http_code}\n' -X PUT "$BASE/admin/realms/$REALM" -H "$H" \
      -H 'Content-Type: application/json' --data-binary @-
```

## Bind it

```bash
# Realm-wide: browserFlow takes the flow's ALIAS.
curl -s -o /dev/null -w 'realm: %{http_code}\n' -X PUT "$BASE/admin/realms/$REALM" -H "$H" \
  -H 'Content-Type: application/json' -d "{\"browserFlow\":\"$FLOW\"}"

# Or one client: authenticationFlowBindingOverrides.browser takes the flow's ID, not its alias.
FLOW_ID=$(curl -s "$BASE/admin/realms/$REALM/authentication/flows" -H "$H" \
  | jq -r --arg a "$FLOW" '.[] | select(.alias == $a) | .id')
CID=$(curl -s "$BASE/admin/realms/$REALM/clients?clientId=<client-id>" -H "$H" | jq -r '.[0].id')
curl -s "$BASE/admin/realms/$REALM/clients/$CID" -H "$H" \
  | jq --arg f "$FLOW_ID" '.authenticationFlowBindingOverrides.browser = $f' \
  | curl -s -o /dev/null -w 'client: %{http_code}\n' -X PUT "$BASE/admin/realms/$REALM/clients/$CID" \
      -H "$H" -H 'Content-Type: application/json' --data-binary @-
```

Ask which the user wants — realm-wide affects every client including admin/account consoles.

## Behaviors worth knowing

- **The password step is standard Keycloak, so its brute-force/lockout behavior is whatever the
  realm already has configured** — this flow doesn't need a separate brute-force decision the
  way the fully-passwordless email-OTP flow does, since the password step already gates guessing
  before the OTP is ever reached. Still confirm the realm's brute-force settings are on if that
  matters generally.
- **No separate "forgot password" bypass is introduced here.** This flow adds a second factor on
  top of existing password login; it does not change how the password itself is reset or
  recovered.
- **A fresh test user with no name on file** can pick up `VERIFY_PROFILE`, interrupting
  right after a correct OTP with an unrelated profile screen — not a flow bug.
- Everything else about `ext-email-otp` (no independent code TTL, code cached in an auth-session
  note, built-in resend) is identical to the passwordless variant — see
  `admin-email-otp-login.md` for those details rather than duplicating them here.

## Verifying this actually works

1. Correct username + correct password → OTP form appears, a real code is emailed.
2. Submit the emailed code → returns to the app's `redirect_uri` with an authorization code,
   `state` unchanged.
3. Correct username + **wrong** password → rejected immediately, generic message, **confirm no
   mail was sent** — this is what proves the password gates the OTP rather than merely preceding
   it cosmetically.
4. Unknown username → same generic rejection as (3), no separate signal.

## Troubleshooting

| Symptom | Cause |
| --- | --- |
| OTP email never arrives even after a correct password | Realm SMTP unconfigured or wrong — the send failure is swallowed |
| A wrong password still reaches the OTP form | The flow was authored with the wrong identifier step (e.g. `ext-auth-username-auth-note`, which doesn't check a password at all) instead of `auth-username-password-form` |
| Unknown users get a different error than wrong-password users | Not the behavior of the stock combined form — check nothing custom was layered in front of it |
| `ext-email-otp` not offered when adding an execution | The `keycloak-magic-link` extension isn't installed |
| A fresh test user hits an unrelated profile-completion screen after a correct code | `VERIFY_PROFILE` required action from a missing name on the user — not a flow bug |

Files in this skill

  • SKILL.md11.9 KB
  • assets/username-password-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.