Skip to content
Back to skills

PasswordlessMagicLinkLogin

BSecurity

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 d...

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

Works with

  • cli
  • api

Security analysis

B88/100
  • criticalExfiltrates credentials via HTTP — exact pattern from Snyk ToxicSkills study

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

Installs into .claude/skills of the current project.

Are you the author of PasswordlessMagicLinkLogin?

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

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

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: 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.md12.9 KB
  • scripts/verify_magic_link.py8.3 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.