Sign-in Paths
There is more than one way to obtain a credential for a MemQL cluster, and they are not interchangeable. This page says what each path is FOR, when it is the right one, what recovery looks like when mail does not work, and why a passkey does not follow you from one cluster to another.
Everything here is stated against the shipped code. Where something is not implemented, this page says so rather than describing the intent.
The five paths at a glance
| Path | What it is | Who it is for | Where it ends up |
|---|---|---|---|
| Magic link | An emailed single-use link, 10-minute default TTL | Anyone with a working mailbox on a cluster that can send mail | OAuth auth code -> /oauth/token -> access + refresh token |
| Passkey | A WebAuthn discoverable credential, bound to this cluster's RP ID | A returning person on a cluster where they have already enrolled one | The same OAuth auth code a magic link produces |
| Enrolment link | A single-use mql_enr_ URL that authorizes exactly one action: register a passkey as the named user | Someone who has no credential yet AND no mailbox that works here -- the local-install case | A passkey, which is then used to sign in |
| Device code | RFC 8628: a short code shown on one device, approved in a browser on another | A host that cannot bind a loopback port or cannot open a browser at all | Access + refresh token on the polling device |
| PAT | mql_pat_ long-lived bearer, shown once at generation | CLI tooling talking to the identity binary | Nothing -- the PAT itself is the credential |
Choosing between them
Work down this list and stop at the first line that is true.
- You are a person, in a browser, and you already have a passkey on this
cluster. Use the passkey.
/loginrenders a "Sign in with a passkey" button next to the email form. - You are a person, in a browser, and the cluster can send mail. Use the
magic link. It is the default path on
/loginand the universal recovery route (see Recovery). - You are a person with no credential on a cluster that cannot send mail -- typically a freshly installed local cluster. You need an enrolment link, minted by an owner/admin from the console's Users app or by the install wizard.
- You are an editor or a CLI on a machine with a browser and a bindable
loopback port. Use the browser sign-in flow (the VS Code extension's
MemQL: Sign In). It runs the standard authorization-code + PKCE flow
against a one-shot
127.0.0.1listener. - You are on a headless box, in a container, or behind a network where the
browser cannot reach
127.0.0.1. Use the device code flow -- in the editor, MemQL: Sign In With a Device Code. See Reaching the device grant from the editor. - You are a script talking to the identity service itself. Use a PAT. Note the hard limit below: a PAT does not authenticate against mesh nodes.
WARNING: a PAT (mql_pat_...) and a worker token (mql_wkr_...) are refused by
every non-identity node. PAT verification is a database lookup that only the
identity binary wires (component/identity/verifier/verifier.go returns
PAT path not wired on this node when no PAT verifier is present), so a valid
PAT fails against the bff exactly as a forged one does. To dial the bff you need
an identity-issued JWT access token.
Magic link
The default, and the one every other path is measured against.
- Issued by
POST /auth/magic-linkorPOST /login. The click lands onGET /auth/complete, which renders a confirmation page and writes nothing; the state change is the POST that follows. - Single-use, token-hashed at rest, 10-minute default TTL
(
MEMQL_IDENTITY_MAGIC_LINK_TTL_SECONDS, default 600). Consume is a compare-and-swap under a Postgres advisory lock, so exactly one finisher wins. - Device-bound. Issue also mints a 32-byte nonce, stores only its digest
on the row, and hands the plaintext to the requesting browser as the
memql_mlcookie. A link COMPLETES only in a browser holding that cookie; a click anywhere else can only APPROVE the request, and the requesting tab (sitting on/check-email) polls, notices, and finishes itself. If somebody else clicks your link, you sign in and they get nothing. - The row carries the in-flight OAuth context --
clientId,redirectURI,state,codeChallenge,codeChallengeMethod-- so finishing mints an auth code for the client that started the flow.stateno longer travels in the emailed URL: it stayed on the row, and the cookie replaced what echoing it proved. - Both issue routes are wrapped in the anti-abuse stack: per-IP rate limit, Turnstile, disposable-domain blocklist, MX validation, risk scoring. See identity-service.md.
- It can be turned off per account.
signInPolicy=passkey_onlymakes a request for a link send a notice instead. See access-model.md.
It needs two things the other paths do not.
Outbound mail. With neither
Microsoft Graph nor SMTP configured, the email integration falls back to a log
sender, which writes the message body -- link included -- to the identity pod's
log instead of delivering it. That is exactly the situation the enrolment link
exists for, and it is also what the install wizard's magicLink step reads.
A mailbox only you read. Anyone who can read the address can request a
link for the account, and no amount of device binding changes that -- binding
stops somebody riding your link, not somebody asking for their own. On a
group alias (team@, support@) the account's sign-in surface is the
mailbox's reader list. The sharedMailbox hint makes that visible and
passkey_only closes it; both are in
access-model.md.
Passkey
A passkey is the tenth variant of v1:identity:identity, and the only credential
family in that union whose stored material is public: the row carries a COSE
public key, and possession is proved by a signature over a server-issued
challenge.
Registration -- POST /auth/webauthn/register/{begin,finish}. Two
authorizations are accepted, and only these two:
- an authenticated user-class Bearer JWT (you already have a session and are adding an authenticator), or
Authorization: Enrolment mql_enr_<...>-- an enrolment link, which is how a FIRST passkey is obtained with no session at all.
Login -- POST /auth/webauthn/login/{begin,finish}. Unauthenticated by
nature; this pair IS the authentication. It is usernameless: the challenge
carries an empty allowCredentials, because no email has been typed when the
button is pressed. That works only because registration mints credentials with
residentKey=required and userVerification=required, so the authenticator
holds the user handle itself. The assertion resolves to a row by credential id
alone, which is why a credential id already bound to another account is refused
cluster-wide at registration.
Properties worth knowing as an operator:
- The RP ID comes from
MEMQL_IDENTITY_BASE_URL, never from the requestHostheader. A Host-derived RP ID would let anyone who can reach the service under another name have credentials minted for a domain they control. A base URL that yields no usable RP ID makes the passkey routes refuse rather than guess. - Challenges are server-minted, single-use, ceremony-tagged (a registration challenge cannot be redeemed as a login assertion), and TTL'd at 5 minutes.
- The challenge store is in-memory and therefore per-replica. That is correct for identity as deployed -- a ceremony is two requests seconds apart -- but it is the thing that has to move to the graph if identity is ever put behind a round-robin mid-ceremony.
- A sign-count regression is refused and audited as the cloned-authenticator signal. A counter that is zero on both sides is NOT that case: iCloud Keychain and Windows Hello never implement counters and report 0 forever.
- The login ceremony ends in the same OAuth auth code
/auth/completeproduces, PKCE binding intact. No client learns which factor ran.
The /login passkey button is a progressive enhancement twice over: the block
renders hidden and only passkey-login.js reveals it, and it renders at all
only when a relying party is in scope (client_id and redirect_uri both
present). With no client there is nothing for an assertion to produce, so the
magic-link form is the way in.
Enrolment link
The credential that removes email from the critical path.
- Wire format
mql_enr_<43 base64url chars>-- 32 CSPRNG bytes. SHA-256 hex at rest; the plaintext is returned to the issuer once, travels in the link, and is never persisted or logged. - Single-use, marked by a
consumedAtstamp. The stamp lands on the registration FINISH call, server-side -- so a reload or an abandoned tab does not burn a link that produced nothing. - 15-minute default TTL, clamped to a 24-hour ceiling. A request above the ceiling is clamped, not refused.
- It authorizes exactly one action: register a passkey as the named user. That narrowness is what makes it safe to hand out as a plain bearer in a URL.
- Redeemed at
GET /enroll?code=..., which validates the token and renders the registration page. HTTPS is required on both issue and redeem, the route is per-IP rate-limited, and every outcome -- including each refusal -- is audited with the source IP.
Four rejection states, each with its own message and its own error code, because each asks the holder for a different next step:
| State | HTTP | Error code | Means |
|---|---|---|---|
invalid | 401 | enrolment_invalid | No row matched -- a typo, a truncated link, or a token from another cluster |
expired | 410 | enrolment_expired | The row aged past expiresAt before anyone used it |
already-used | 409 | enrolment_already_used | consumedAt is stamped. This is where a replay lands |
revoked | 403 | enrolment_revoked | The issuer killed it before it was used |
Two issuers, and the split matters:
- An owner or admin, from the console's Users app. Rides
IdentityAdminMsgonMemqlService.Stream; the gate lives incomponent/identity/adminops. The composed link must behttps-- the issuer refuses to emit a plaintext one. - The install wizard, via the
enrolmentLinkgraph step, which runsmemql enrolment-token mintinside the identity pod (scripts/install/enrolment-link.sh,kubectl exec deploy/identity). This is the only authority available at that moment: nothing can authenticate to a cluster whose owner has just been bootstrapped from env, and/setupis already gone (it 404s once any user exists, and the wizard's unattended bootstrap completes setup from env before/setupwould ever render).
The CLI form takes --user-id or --user-email, an optional --ttl, and
refuses to emit an http:// link unless --allow-insecure is passed.
Device code
RFC 8628, for the case the loopback redirect cannot serve.
POST /device/codemints two credentials: a machine-helddevice_code(mql_dvc_<43>, 32 random bytes) and a human-typeduser_coderenderedXXXX-XXXX. Neither plaintext is persisted -- only SHA-256 digests reach the engine.- The
user_codealphabet isABCDEFGHJKLMNPQRSTUVWXYZ23456789-- 32 symbols, 8 characters, 40 bits. Every confusable pair is broken by REMOVING one member (no0/O, no1/I) rather than by aliasing on input. Input is accepted case-insensitively, with or without the separator. - Default window 10 minutes (
MEMQL_IDENTITY_DEVICE_CODE_TTL_SECONDS, capped at 30 minutes). Default poll interval 5 seconds. - The human half is
GET/POST /device, which requires a signed-in user before it will resolve a code at all, and shows the approver which application is asking, from which IP, calling itself what. A device flow that shows only the code is a phishing primitive. - Redemption is a grant on
POST /oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code.
The five error codes the token endpoint returns, and what each means:
| Code | Meaning | Terminal |
|---|---|---|
authorization_pending | The human has not answered yet | no |
slow_down | You polled sooner than interval; the interval has been raised | no |
access_denied | The human said no | yes |
expired_token | The window closed | yes |
invalid_grant | Unknown, replayed, or wrong client_id | yes |
INFO: slow_down is not advisory. The server raises the interval on the
persisted row (+5s per offence, capped at 60s) and judges every later poll
against the raised value, so a client that ignores the interval in the error
body ratchets itself to the ceiling.
If the node has no device-code store wired, POST /device/code answers
temporarily_unavailable and the grant refuses -- a binary without an engine
does not half-serve the flow.
Personal access token (PAT)
mql_pat_<43 base64url chars>, 32 random bytes. Shown once at generation; only the SHA-256 hex hash is stored, on av1:identity:identityrow ofidentityType="api_key".- Long-lived by design -- there is no TTL on the token itself.
- Issued from the identity web UI at
/me/tokens. - Identity-binary only. See the warning at the top of this page.
The editor's sign-in
The VS Code extension exposes MemQL: Sign In and MemQL: Sign Out on each
cluster in the Clusters view (memql.clusters.signIn / memql.clusters.signOut),
plus MemQL: Sign In With a Device Code in the palette.
The flow needs an issuer, which is a different fact from the endpoint the
stream dials -- identity.<domain> versus api.<domain>. A cluster naming
neither an issuer nor a domain has nowhere to sign in to, which is why a dial
that fails on the credential offers a Sign in button only when both
signInCanRecover (the failure is one a fresh token fixes) and canSignIn (an
issuer exists) hold. The context-menu command itself is always present.
Where the resulting tokens live is a deliberate split:
| Credential | Stored in | Why |
|---|---|---|
| Access token | ~/.memql/clusters.yaml, token: | Short-lived, and the MemQL Cockpit reads the same file. A cluster the extension has signed in to but which carries no credential at all would read, to the Cockpit, as a cluster nobody has signed in to |
| Refresh token | VS Code SecretStorage, keyed per cluster | A 30-day credential has no business in a plaintext shared file. clusters.yaml's refresh_token: key is an INGEST path only -- on the first successful exchange the token moves into SecretStorage and the plaintext key is deleted |
| Access-token expiry | SecretStorage, beside the refresh token | Redundant for today's JWT (exp is inside it); it exists so an opaque access token could still be renewed proactively |
SecretStorage cannot be enumerated -- there is get, store and delete and
no way to ask what keys exist -- so the store keeps its own index of the clusters
it has written for, and offers a rename-as-move and a reconcile sweep over it.
Both are wired (memql#3515):
- Renaming a cluster moves its credential.
MemQL: Edit Clustergoes throughsaveClusterEdit, which writes the entry and then moves theSecretStoragehalf to the new name. The file write runs first, because it is the half that can fail -- a secret moved to a name no entry carries is the orphan the move exists to prevent. - A sweep runs once at activation, deleting the secrets of clusters
clusters.yamlno longer carries. It is what cleans up after a cluster deleted by the Cockpit or by hand. A registry that cannot be parsed sweeps nothing, deliberately: "I could not read it" and "there are none" must not delete the same credentials.
Reaching the device grant from the editor
MemQL: Sign In With a Device Code (memql.clusters.signInWithCode, palette
only) runs the device grant deliberately, skipping loopback. Use it when you
already know this host cannot do a browser round trip -- otherwise you sit
through the callback deadline first only to be told so.
MemQL: Sign In reaches the same grant by itself when the browser round trip
proves impossible (memql#3515) -- both commands run through one sign-in shell
that differs only in which flow it starts. The rule for when it switches is
worth knowing because it says which failures are environment limitations and
which are refusals. It triggers on environment limitations that are knowable
before or at browser-open, branching on the failure's kind and never on
message text:
bindFailed-- the one-shot loopback listener could not bind127.0.0.1:0.browserUnavailable-- this host cannot open a browser at all.
Both fire before any page could have opened, which buys the invariant the whole
flow leans on (memql#4594): the device flow never starts while a browser tab
may still complete. timeout was a trigger until memql#4594 and is
deliberately no longer one: the browser sign-in's primary human factor is a
magic-link round trip (enter email, wait for the mail, click, approve), which
routinely outlives any deadline -- and the auto-switch fired under a live tab,
closed the listener that tab was about to redirect to, and stacked a second
flow's notifications over the first. The callback deadline is now ten minutes
(the magic-link TTL scale), the progress notification stays cancellable the
whole time and explains itself after a quiet minute, and a timeout is reported
as a warning whose advice names MemQL: Sign In With a Device Code for the host that
genuinely cannot receive the callback.
It also deliberately does not trigger on cancelled (the user stopped it),
stateMismatch (a security refusal -- retrying it through a second channel is
the bug the rule exists to prevent), exchangeRejected, authorizationDenied,
invalidCallback, misconfigured or registrationFailed.
Recovery when mail does not work
Where mail works, magic-link remains the universal recovery route. It needs no prior credential, no enrolled device and no second screen: type the address, open the link. Every other path on this page assumes something you might have lost.
On a local cluster with no mail configured, recovery is the wizard re-minting an enrolment link. That is the intended answer, not a gap being papered over, and the reason is worth stating plainly rather than leaving a reader to wonder whether it is a hole:
Minting an enrolment link on a local cluster requires the ability to run
kubectl execagainst the identity pod on that machine -- which is to say, filesystem and Docker access to the machine the cluster runs on. Anyone with that already has total authority over the cluster: they can read the database directly, edit the manifests, replace the images, or setMEMQL_IDENTITY_ENABLED=falseand be admitted as the cluster owner. The enrolment link grants nothing its holder did not already have. What it buys is that the recovery is a supported, audited, single-use, 15-minute action instead of an improvised one.
Concretely, from the machine hosting the cluster:
kubectl exec -n memql deploy/identity -- \ /app/memql enrolment-token mint --user-email owner@example.comOpen the printed link, create a passkey, and you are back in. The wizard's
enrolmentLink graph step is the same call, run for you.
Two things this recovery is NOT:
- It is not a remote recovery route. It deliberately requires local authority over the cluster, which is precisely why it is safe.
- It is not available to a person who has lost access to a cluster somebody else operates. There, the answer is a magic link if mail works, or an owner/admin issuing them an enrolment link from the console's Users app.
The install wizard also retains the pod-log magic-link read as a documented
fallback (its magicLink graph step) for the case where the enrolment link
cannot be minted or opened.
Per-cluster passkey semantics
This is expected behaviour, and it is the first thing to explain to someone who hits it.
A passkey is bound to its RP ID, and this cluster derives that RP ID from
MEMQL_IDENTITY_BASE_URL. Two consequences follow directly, and neither is a
defect:
- A passkey does not work across clusters. A credential enrolled against
identity.example.comis not offered by the browser atidentity.other.example.com, and could not be verified there if it were -- the RP ID is hashed into the credential. Staging and production are different relying parties; so are two local installs on different domains. Enrol once per cluster you sign in to. - Reinstalling a local cluster orphans its passkeys. A reinstall creates a
fresh database, so the
v1:identity:identityrows carrying the public keys are gone. The authenticator still holds its half and the browser may still offer it, but the server has nothing to resolve the credential id against and the assertion is refused as unknown. The stale entry is harmless -- it is a public key nobody can use. Clean it up from/me/devices(memql#3409): the page lists enrolled passkeys -- label, added, last used, an AAGUID-derived model name, and the backup posture that says whether losing the device loses the credential -- with rename and revoke actions, and a route to enrol another via the memql#3406 ceremony. Revoke is a soft delete (revokePasskeyIdentity,dsl/identity/mutations.memql): the row stays for audit and its credential id stays taken so the same authenticator cannot be re-enrolled onto a fresh row. A revoke that would leave the account with no working sign-in route is warned about before it happens.
The mitigation is structural: the install wizard mints a fresh enrolment link on every install. A reinstall therefore ends the way a first install does -- with a link that produces a working passkey for the new cluster -- rather than with an operator wondering why the passkey they enrolled last week no longer signs them in.
What the RP ID actually is
It is the hostname of MEMQL_IDENTITY_BASE_URL, with any port stripped (a
port belongs to an origin, not to a domain). The expected ceremony origin is the
full scheme://host[:port], which is what makes a localhost dev deployment on
a non-standard port work with no special case.
Cross-device (phone / QR) enrolment on a local cluster: UNVERIFIED
Do not plan around scanning a QR code with a phone to enrol a passkey on a local cluster. The question splits into two legs, and only one of them is settled (memql#3405):
- The browser's RP ID validator: settled. A
.localhost-family RP ID passes the HTML is a registrable domain suffix of or is equal to algorithm, both when the RP ID equals the origin's host and when it is a parent domain. That turns onlocalhostnot appearing on the Public Suffix List, verified against the live list. Note the direction of that result: it passes because nothing has been asserted about it, which is a thinner guarantee than a spec carve-out. - The authenticators: not answered. Whether the iOS and Android passkey
providers accept such an RP ID over hybrid (cross-device) transport requires
physical hardware and a person to scan a code and present a face or a finger.
That measurement has not been run. The mechanism is sound in principle --
in hybrid transport the desktop browser does all the network I/O and the phone
only does cryptography, so the RP ID reaches it as an opaque string -- but
"sound in principle" is exactly what the spike exists to stop us shipping on.
The same is untested for a local
*.memql.localhost-style domain, so this is not narrowly about.localhost.
So this page promises the platform authenticator only: Touch ID, Windows
Hello, or the screen lock on the device in front of you. That is a constraint on
what is PROMISED, not on what works -- the browser will still offer whatever it
supports, cross-device transport included, and a user for whom that works gets
it. The /enroll page's copy is written under the same constraint and names the
platform authenticator only.
Not implemented
Stated here so nobody plans against it:
- There is no passkey-only account model. A user row still carries a primary email; the enrolment link removes email from the sign-in path, not from the data model.
The editor's "+" Install / Repair actions do not run an installer.Superseded. The extension now runs the install / repair graph in-process: the "+" Add Cluster panel and the cluster panel's Repair action both callrunInstall(editors/vscode/src/install/session.ts), the same entry point the CLI's own install command uses. Repair is not a separate mode -- every step verifies first and skips when already satisfied, so re-running the graph against a cluster that stopped answering IS the repair.
Related
- Identity Service (Operator Guide) -- env vars, endpoints, key management.
- Access Model -- enforcement layers and the role spectrum.
- User Provisioning -- registration modes and the magic-link flow.
- Account Tokens, Node JWT, Service-account JWT -- the machine-credential families.