Skip to content
Back to skills

EmailOtpLoginRest

ASecurity

Turn on passwordless login by a one-time code emailed to the user, on a self-managed Keycloak running the p2-inc keycloak-magic-link extension, using the Admin REST API only (no 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 (none ships auto-created), the load-bearing identifier-step choice that decides whether the realm leaks which addresses have accounts, realm SM...

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

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

Installs into .claude/skills of the current project.

Are you the author of EmailOtpLoginRest?

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

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

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: emailOtpLoginRest
description: >-
  Turn on passwordless login by a one-time code emailed to the user, on a self-managed
  Keycloak running the p2-inc keycloak-magic-link extension, using the Admin REST API only
  (no 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 (none ships auto-created), the load-bearing identifier-step choice that decides
  whether the realm leaks which addresses have accounts, realm SMTP, and why brute-force
  protection matters for a 6-digit code. Not Keycloak's TOTP/HOTP authenticator apps, and
  not magic link (a clicked link rather than a typed code).
---

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

# emailOtpLoginRest

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

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

| | Magic link | Email OTP (this doc) |
|---|---|---|
| What arrives by email | 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 — a flow must be authored** |
| Works if the mail client rewrites links | Fragile | Unaffected (nothing to click) |

Both come from the same extension, and both need realm SMTP.

## Prerequisite: the keycloak-magic-link extension

`ext-email-otp` is provided by **[p2-inc `keycloak-magic-link`](https://github.com/p2-inc/keycloak-magic-link)**
(the same jar that provides `ext-magic-form`), not by stock Keycloak. Phase Two's own Keycloak
distribution bundles it. Confirm it's present before anything else:

```bash
BASE=http://localhost:8080/auth       # include the relative path if one is configured
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 the extension isn't installed — say so and stop. There is no stock-Keycloak
equivalent (Keycloak's own OTP authenticators are TOTP/HOTP against a registered authenticator
app, a different mechanism requiring device enrollment).

## Author the flow — no built-in one exists

Unlike magic-link (where the provider auto-creates a `magic link` flow on every realm), **nothing
auto-creates an email-OTP flow**. Confirm none exists, then author one.

The shape, from [`../assets/email-otp-flow.partial-import.json`](../assets/email-otp-flow.partial-import.json):

| Sub-flow | Execution | Requirement | Why it's here, and why in this position |
|---|---|---|---|
| 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 | `ext-auth-username-auth-note` | REQUIRED | **Load-bearing, and the choice here matters.** `ext-email-otp` reads the *attempted username* off the auth session (`MagicLink.getAttemptedUsername`) rather than collecting an address itself, so an identifier step must run first. Use `ext-auth-username-auth-note` (p2-inc `keycloak-orgs`) here, **not** stock `auth-username-form` — see the anti-enumeration section below for why this choice is not cosmetic. |
| forms | `ext-email-otp` | REQUIRED | Generates the code, mails it, renders the code form, and verifies the input. |

**Do not put `ext-email-otp` first, and do not drop the identifier step** — that combination
produces a silent no-email outcome rather than an error.

Two authoring paths, and one that does not work:

| Path | Cost | Requires |
|---|---|---|
| `POST /admin/realms/{realm}/authentication-flow/import?force={bool}` — authors the flow **and** binds it 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 → create 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.** No handler for authentication flows: HTTP 200, `added: 0`, nothing created, no error. |

Offer the extension when it isn't installed (it 404s clearly). Strip `ifResourceExists` from the
asset before sending — the atomic payload rejects unknown fields — and read the created alias back
from the response, since the extension hash-prefixes it.

Manual path, if that's the choice:

```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}"

# The sub-flow is created AS an execution of the parent.
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 ext-auth-username-auth-note ext-email-otp; do   # order matters, see the table above
  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" ext-auth-username-auth-note 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 identifier-step choice: this is where anti-enumeration is actually decided

**Verified empirically, not just from source** — this is the trap that's easy to get wrong even
having read the authenticator code correctly, because the leak comes from a *different*
authenticator entirely:

- Wiring the identifier step as stock **`auth-username-form`** makes an unregistered address
  return **"Invalid username or email."** on the spot, before `ext-email-otp` ever runs. That's a
  real enumeration leak — the response genuinely differs for a registered vs. unregistered
  address — and it happens regardless of `ext-email-otp`'s own force-create setting, because the
  rejection happens one step earlier.
- Wiring it as **`ext-auth-username-auth-note`** (the p2-inc `keycloak-orgs` authenticator used
  for exactly this purpose elsewhere in this router) sets the attempted username without
  validating existence, and control reaches `ext-email-otp`, whose own null-user handling *is*
  anti-enumeration-safe: with force-create off, an unmatched address renders the identical code
  form with no email sent and no error.

So "an unregistered address gets an identical response" is **not automatically true of email
OTP** — it depends entirely on which authenticator collects the identifier, and the answer for
the asset in this repo is `ext-auth-username-auth-note`. If you ever see this flow authored with
`auth-username-form` instead (including in fixtures that predate this correction), treat the
enumeration leak as real and swap the authenticator rather than assuming the "same as magic-link"
behavior applies.

## The one config option — and how it differs from magic-link

`ext-email-otp` has exactly **one** configurable property, and it is the *same key* magic-link
uses (a shared constant in the extension), which is why it looks out of place:

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

```bash
curl -s -X POST "$BASE/admin/realms/$REALM/authentication/executions/<ext-email-otp-exec-id>/config" \
  -H "$H" -H 'Content-Type: application/json' \
  -d '{"alias":"email-otp-config","config":{"ext-magic-create-nonexistent-user":"false"}}'
```

**The default differs from magic-link's, in the safe direction.** Verified in the extension's
source: `ext-magic-form` defaults this to `true` (which silently provisions an account for any
address typed — the trap `admin-passwordless-magic-link.md` warns about), whereas
`ext-email-otp` defaults it to **`false`**. So for email OTP you do *not* have to remember to
turn it off. Setting it explicitly, as the asset does, is documentation rather than a fix — but
do set it to `true` deliberately if self-registration-by-OTP is actually wanted (note that doing
so also reopens the enumeration question above, since a match will now always exist).

Nothing else is configurable. In particular the code itself is **not** tunable: it is always a
6-digit numeric string (`SecretGenerator.DIGITS`), with no length, alphabet, or separate TTL
setting.

## Realm mail settings — required, and it fails silently

Same dependency and same trap as magic-link: a realm ships with **no SMTP settings at all**, and
a failed send is swallowed internally. Read-modify-write the realm representation (a PUT that
omits fields resets them):

```json
{
  "smtpServer": {
    "host": "smtp.example.com", "port": "587", "from": "noreply@example.com",
    "auth": "true", "user": "...", "password": "...", "starttls": "true", "ssl": "false"
  }
}
```

```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 @-
```

Verify by checking what actually left, not by trusting a `204`.

## 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 the admin and account
consoles, which usually should keep their own login path.

## Behaviors worth knowing before you're asked about them

All verified empirically against a live deployment, not just from source:

- **Anti-enumeration depends on the identifier step — see the section above.** With
  `ext-auth-username-auth-note`, an unregistered address gets the identical code-form response,
  no email, no error. It also means "I never got a code" is indistinguishable from "that address
  has no account" — by design, once the right identifier step is used.
- **Refreshing the page does not resend.** The code lives in an auth-session note
  (`user-auth-note-otp-code`) and the send is skipped whenever that note is already set.
- **There is a built-in resend.** Posting a `resend` form parameter clears the note and issues a
  fresh code. The bundled `otp-form.ftl` exposes this.
- **No independent code expiry.** The code has no TTL of its own; it lives as long as the
  authentication session does. Shorten the realm's login timeouts if you need a tighter window.
- **Wrong codes feed brute-force protection.** A mismatch is reported as
  `LOGIN_ERROR`/`invalid_code` and raised as an `INVALID_CREDENTIALS` failure challenge, so the
  realm's brute-force settings actually govern guessing attempts. Realms ship with brute-force
  **off** — a 6-digit code with unlimited attempts is weak, so turn it on when using this flow.
- **Success marks the email verified.** Completing an email-OTP login sets `emailVerified=true`
  on the user, since possession of the address was just proven. Useful, but be aware it mutates
  the user.
- **New users need a name on file.** A user created without `firstName`/`lastName` can pick up a
  `VERIFY_PROFILE` required action, which interrupts the flow with an unrelated profile-completion
  screen right after a correct code. Not specific to email OTP, but easy to mistake for a flow bug
  when testing against a freshly created user.

## Verifying this actually works

Configuration alone proves nothing — drive a real login:

1. Start an authorization-code request against the bound client. The identifier form appears;
   submit a **registered** address. The code form (`otp-form.ftl`) appears, and an email arrives
   with a 6-digit code.
2. Submit the emailed code → the browser returns to the app's `redirect_uri` with an authorization
   code, `state` unchanged.
3. Submit a **wrong** code → rejected with an invalid-code message, and the attempt counts toward
   brute force if enabled.
4. Repeat step 1 with an **unregistered** address → the code form still appears (identical
   response, assuming `ext-auth-username-auth-note` is the identifier step), but confirm **no
   mail was sent** and **no user was created**.

## Troubleshooting

| Symptom | Cause |
| --- | --- |
| Code form appears but no email ever arrives, for any address | Realm SMTP unconfigured or wrong — the send failure is swallowed |
| Unregistered address gets "Invalid username or email" instead of the code form | The identifier step is stock `auth-username-form`, which validates existence up front — swap it for `ext-auth-username-auth-note` |
| No email, and the flow seems to skip straight to the code form with nothing to send to | The identifier step is missing or ordered *after* `ext-email-otp`, so there is no attempted username to mail |
| A brand-new account appears for an address nobody registered | `ext-magic-create-nonexistent-user` was set to `"true"` |
| Codes accepted indefinitely on repeated guesses | Realm brute-force protection is off (the default) |
| `ext-email-otp` not offered when adding an execution | The `keycloak-magic-link` extension isn't installed on this Keycloak |
| A refresh doesn't produce a new code | Expected — use the form's resend control; the code is cached in an auth-session note |
| 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 |

Files in this skill

  • SKILL.md16.5 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.