PasswordlessMagicLinkLogin
BSecurityTurn on passwordless login by emailed link ("magic link") in Keycloak, using the real p2-inc keycloak-magic-link provider. Use this whenever someone wants "passwordless login", "log in with a magic link", "email people a login link instead of a password", or "let users sign in without a password" — even if they only say "passwordless". Covers the built-in "magic link" flow the provider auto-creates on every realm (no custom flow authoring needed, only binding it), the realm SMTP settings it d...
- 4 stars
- 0 votes
- 0 copies
- 0 views
- Added October 15, 2026
Works with
Security analysis
88/100- Exfiltrates credentials via HTTP — exact pattern from Snyk ToxicSkills study
Pro scans all 2 files and shows the line behind each finding
npx -y skills add p2-inc/keycloak-skills --skill passwordlessMagicLinkLogin --agent claude-codeAre you the author of PasswordlessMagicLinkLogin?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/p2-inc-passwordlessmagiclinklogin)---
name: passwordlessMagicLinkLogin
description: >-
Turn on passwordless login by emailed link ("magic link") in Keycloak, using the
real p2-inc keycloak-magic-link provider. Use this whenever someone wants
"passwordless login", "log in with a magic link", "email people a login link instead
of a password", or "let users sign in without a password" — even if they only say
"passwordless". Covers the built-in "magic link" flow the provider auto-creates on
every realm (no custom flow authoring needed, only binding it), the realm SMTP
settings it depends on, the anti-enumeration behaviour that makes registered and
unregistered addresses look identical from the login page, and the
create-user-if-none-exists setting that silently provisions an account for anyone's
email unless turned off. Not WebAuthn/passkey passwordless (a different mechanism —
see bindingAuthenticationFlow's Passwordless-or-password), and not a one-time
password sent as a code to type in (that is ext-email-otp, a sibling authenticator
in the same provider, not covered here).
---
<!-- Copyright 2026 Phase Two, Inc. -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
# Passwordless login by magic link
## What "magic link" actually is
The user clicks a link, does not enter a password, and is signed in. Mechanically:
Keycloak mints a signed, time-limited action token, embeds it in a URL, and emails
that URL. Opening the link authenticates the same way any other action-token flow
does (email verification, password reset) — it is not a bespoke security model, and
that is worth saying if anyone asks whether it is "as secure" as a password: it is
exactly as strong as email delivery and the token's own lifespan.
## Set up one admin REST helper first — tokens expire after 60 seconds
Every call below goes to Keycloak's admin REST API with an admin access token. A token
from `admin-cli` in the `master` realm **expires after 60 seconds** by default, so a
token minted once and reused goes stale partway through the task. A `401 Unauthorized`
halfway through means the token expired — **not** that the endpoint, method, or body
is wrong. Don't reuse a token: write this helper once, with the admin credentials you
were given, and it mints a fresh token for every call.
```bash
cat > /tmp/kc <<'EOF'
#!/bin/sh
# kc METHOD PATH [curl args...] — one admin REST call with a freshly minted token.
# Body goes to stdout (pipe it to jq); "HTTP <status>" goes to stderr.
BASE=http://localhost:8080/auth # include the relative path if one is configured
KC_USER=<admin-user> KC_PASS=<admin-password>
m=$1 p=$2; shift 2
t=$(curl -s "$BASE/realms/master/protocol/openid-connect/token" -d client_id=admin-cli \
-d grant_type=password --data-urlencode "username=$KC_USER" \
--data-urlencode "password=$KC_PASS" | jq -r .access_token)
exec curl -s -X "$m" "$BASE/admin/realms$p" -H "Authorization: Bearer $t" \
-H "Content-Type: application/json" -w "%{stderr}HTTP %{http_code}\n" "$@"
EOF
chmod +x /tmp/kc
REALM=<realm>
```
Each step below says which status means success. Several succeed with an **empty
body** — that is the success, not a failure to retry.
## Check the provider is actually installed, first
This capability comes from the **p2-inc `keycloak-magic-link` provider**
(`io.phasetwo.keycloak.magic.auth.magic.MagicLinkAuthenticatorFactory`, provider id
`ext-magic-form`), dropped into Keycloak's `providers/` directory. It is not part of
stock Keycloak. Confirm it before doing anything else:
```bash
/tmp/kc GET "/$REALM/authentication/flows" | jq -r '.[].alias' # HTTP 200
```
If **`magic link`** is in that list, the provider is present — proceed below. If it is
not, no amount of realm configuration will produce it: the jar has to be installed
and Keycloak restarted first, and that is outside what an Admin REST session can do.
Say so plainly rather than trying to hand-build a flow around `ext-magic-form` when
the provider offering that authenticator does not exist on this server.
## The flow already exists — bind it, don't build it
The moment the provider is present, Keycloak **auto-creates a built-in `magic link`
flow on every realm**, including realms created after the provider was installed.
This is the detail that makes the task look harder than it is: there is no custom
flow to author, only a binding to make.
```bash
# Bind realm-wide...
/tmp/kc GET "/$REALM" > /tmp/realm.json # HTTP 200
# set "browserFlow": "magic link" in /tmp/realm.json, then PUT it back
/tmp/kc PUT "/$REALM" --data-binary @/tmp/realm.json # HTTP 204, empty body = done
# ...or to one client only, via authenticationFlowBindingOverrides on the client,
# if only one application should go passwordless rather than the whole realm.
```
Only bind it where it belongs. Binding realm-wide switches every application's
password login off in favour of email; a client-level override is the right choice
when just one app should go passwordless while everything else keeps password login.
## Realm mail settings — required, and it fails silently without them
The authenticator sends through Keycloak's own configured mail sender. A realm ships
with **no SMTP settings at all**, and the send call catches its own failure
internally — nothing surfaces to the caller. The login page still shows its normal
"check your email" response either way, so a missing SMTP configuration looks
identical to a working one until you check whether mail actually left.
```json
{
"smtpServer": {
"host": "smtp.example.com",
"port": "587",
"from": "noreply@example.com",
"auth": "true",
"user": "...",
"password": "...",
"starttls": "true",
"ssl": "false"
}
}
```
PUT the whole realm representation with this set — a realm PUT that omits fields
resets them, so read first. Setting `smtpServer` and `browserFlow` in the same
`/tmp/realm.json` and doing one PUT is fine. Expect `HTTP 204` with an empty body.
**Verify by checking what actually left**, not by trusting a `204`. If you have no
real mail provider to point at (a test/sandbox setting), a minimal SMTP-protocol
capture server is enough — Keycloak does not care that nothing downstream is a real
mailbox, only that something answers on the configured host and port.
## The trap: anyone's email creates an account, by default
`ext-magic-form`'s own config carries a "create user if none exists" setting
(`ext-magic-create-nonexistent-user`), and **it defaults to `true`.** Left alone,
typing any email address on the login page — one belonging to nobody — silently
provisions a brand-new account and emails *that* address a working login link. There
is no error, no warning, and no visible difference in what the page shows.
```bash
# 1. Find the ext-magic-form execution's id, and whether it already has a config:
/tmp/kc GET "/$REALM/authentication/flows/magic%20link/executions" \
| jq '.[] | select(.providerId=="ext-magic-form") | {id, authenticationConfig}' # HTTP 200
# 2a. No authenticationConfig yet → attach one. POST only:
/tmp/kc POST "/$REALM/authentication/executions/$EXEC_ID/config" -d '{
"alias": "magic-link-existing-accounts-only",
"config": {"ext-magic-create-nonexistent-user": "false"}
}' # HTTP 201, empty body = done
# 2b. authenticationConfig already set → update that config in place instead:
/tmp/kc PUT "/$REALM/authentication/config/$CONFIG_ID" -d '{
"id": "'"$CONFIG_ID"'", "alias": "magic-link-existing-accounts-only",
"config": {"ext-magic-create-nonexistent-user": "false"}
}' # HTTP 204, empty body = done
# 3. Confirm: rerun step 1 — authenticationConfig now holds an id. Read it with
/tmp/kc GET "/$REALM/authentication/config/$CONFIG_ID" # HTTP 200
```
Three responses here look like errors and are not:
- `GET …/executions/$EXEC_ID/config` returns **404**. That path accepts POST only; the
404 does not mean the path or the execution id is wrong. There is also no list
endpoint at `GET …/authentication/config` (also 404). Read an existing config only by
its id, as in step 3.
- The POST in 2a returns **201 with an empty body**. That is success — don't repeat it.
Each extra POST creates another config and re-links the execution to it.
- A **401** anywhere means the token expired — use `/tmp/kc`, which mints a fresh one
per call, rather than exploring other endpoints.
Turn this off whenever the request implies "our existing staff/customers", not "let
anyone create an account by typing an email" — which is almost always the intent
behind "passwordless login for our users."
Other config on the same execution, all optional: `ext-magic-update-profile-action`
/ `ext-magic-update-password-action` (required actions to add on a newly created
user — only relevant if create-on-demand is intentionally left on),
`ext-magic-allow-token-reuse` (whether the link can be clicked more than once before
it expires; defaults to reusable), `ext-magic-token-life-span` (seconds, defaults to
86400 = 1 day).
## Why registered and unregistered addresses look the same — on purpose
The authenticator deliberately shows the identical "check your email" response
whichever case applies, to avoid letting a login page be used to enumerate which
addresses are registered. This means the *page response* can never be the check for
whether create-on-demand is off — it looks correct either way. The only way to tell
is checking what happened on the *other* side: was mail sent, and did a user get
created, for an address that should have neither.
## Verifying the whole thing works — run the script once
Drive an actual login rather than trusting configuration alone — nothing here has a
status endpoint that says "passwordless is working." `scripts/verify_magic_link.py`, next
to this file, does the whole round trip below in one command:
```bash
python3 scripts/verify_magic_link.py --realm "$REALM" --client <client-id> \
--redirect-uri <app-redirect-uri> --known <an-existing-user's-email> \
--unknown <an-address-with-no-account> \
--mail-dir <capture-dir, if a local SMTP capture server writes one file per mail> \
--admin-user <admin-user> --admin-password <admin-password>
```
Without a capture directory, run it once to trigger the mail, then rerun with
`--link <url>` copied from the real inbox. It prints one PASS/FAIL line per check.
**If it ends with "All checks passed", the configuration is done — stop there** rather
than re-inspecting flows and executions. If a check fails, the line says which
setting to fix; fix that one thing and rerun the script.
**Use this script — don't write your own test instead.** A home-made check is easy to
get subtly wrong and will happily report success when no mail left the server: it
confirms the page said "check your email", which the page says either way. The
script only passes once real mail has arrived and its link has been followed to the
application. If you can't run it (no Python, no `requests`), do the steps below by
hand, and treat "mail arrived" and "the link reached the redirect URI" as the checks
that matter.
What it checks, for reference:
1. Submit a known address on the client's login page. Expect no password field.
2. Confirm mail actually arrived (real inbox, or your capture point) containing a
link with `login-actions/action-token` in it.
3. Open that link with nothing else entered. Expect the browser to land back at the
application's redirect URI with an authorization code.
4. Submit an address belonging to no account. Expect the *same* page response as
step 1 — and confirm, out of band, that no mail went out and no user was created.
Two client-side details, if scripting this rather than clicking through it:
- Keycloak's auth cookies are `Secure; SameSite=None`. A browser sends them over
`http://localhost` anyway (loopback is a secure context); most HTTP client
libraries will not, and every form POST then fails as `cookie_not_found`. Clear the
flag on each response.
- The action-token URL is exactly what a normal login-flow redirect produces — follow
it the same way you would any other redirect chain, including through
`required-action` hops, until it reaches the application's `redirect_uri`.
## Troubleshooting
| Symptom | Cause |
| --- | --- |
| No "magic link" flow on the realm at all | The provider jar isn't installed on this server |
| "Check your email" shown, but no mail arrives, for anyone | Realm SMTP settings are unconfigured or wrong |
| An unregistered address gets a working login link | `ext-magic-create-nonexistent-user` left at its default `true` |
| Login page behaves differently for known vs. unknown addresses | Something downstream (a custom flow, a proxy) broke the built-in anti-enumeration behaviour — this is not stock; investigate what changed it |
| Link works but only once | `ext-magic-allow-token-reuse` is false — expected if that's the intent, otherwise flip it |
| Existing password login broke everywhere | The flow was bound realm-wide when only one client should have gone passwordless |
Files in this skill
- SKILL.md
- scripts/verify_magic_link.py
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.