PasswordEmailOtpMfaRest
ASecurityHarden 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
Works with
Security analysis
100/100Pro scans all 2 files and shows the line behind each finding
npx -y skills add p2-inc/keycloak-skills --skill passwordEmailOtpMfaRest --agent claude-codeAre you the author of PasswordEmailOtpMfaRest?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/p2-inc-passwordemailotpmfarest)---
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.md
- assets/username-password-email-otp-flow.partial-import.json
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.