docsOperate

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

PathWhat it isWho it is forWhere it ends up
Magic linkAn emailed single-use link, 10-minute default TTLAnyone with a working mailbox on a cluster that can send mailOAuth auth code -> /oauth/token -> access + refresh token
PasskeyA WebAuthn discoverable credential, bound to this cluster's RP IDA returning person on a cluster where they have already enrolled oneThe same OAuth auth code a magic link produces
Enrolment linkA single-use mql_enr_ URL that authorizes exactly one action: register a passkey as the named userSomeone who has no credential yet AND no mailbox that works here -- the local-install caseA passkey, which is then used to sign in
Device codeRFC 8628: a short code shown on one device, approved in a browser on anotherA host that cannot bind a loopback port or cannot open a browser at allAccess + refresh token on the polling device
PATmql_pat_ long-lived bearer, shown once at generationCLI tooling talking to the identity binaryNothing -- the PAT itself is the credential

Choosing between them

Work down this list and stop at the first line that is true.

  1. You are a person, in a browser, and you already have a passkey on this cluster. Use the passkey. /login renders a "Sign in with a passkey" button next to the email form.
  2. You are a person, in a browser, and the cluster can send mail. Use the magic link. It is the default path on /login and the universal recovery route (see Recovery).
  3. 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.
  4. 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.1 listener.
  5. 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.
  6. 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.


The default, and the one every other path is measured against.

  • Issued by POST /auth/magic-link or POST /login. The click lands on GET /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_ml cookie. 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. state no 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_only makes 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 request Host header. 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/complete produces, 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.

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 consumedAt stamp. 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:

StateHTTPError codeMeans
invalid401enrolment_invalidNo row matched -- a typo, a truncated link, or a token from another cluster
expired410enrolment_expiredThe row aged past expiresAt before anyone used it
already-used409enrolment_already_usedconsumedAt is stamped. This is where a replay lands
revoked403enrolment_revokedThe issuer killed it before it was used

Two issuers, and the split matters:

  • An owner or admin, from the console's Users app. Rides IdentityAdminMsg on MemqlService.Stream; the gate lives in component/identity/adminops. The composed link must be https -- the issuer refuses to emit a plaintext one.
  • The install wizard, via the enrolmentLink graph step, which runs memql enrolment-token mint inside 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 /setup is already gone (it 404s once any user exists, and the wizard's unattended bootstrap completes setup from env before /setup would 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/code mints two credentials: a machine-held device_code (mql_dvc_<43>, 32 random bytes) and a human-typed user_code rendered XXXX-XXXX. Neither plaintext is persisted -- only SHA-256 digests reach the engine.
  • The user_code alphabet is ABCDEFGHJKLMNPQRSTUVWXYZ23456789 -- 32 symbols, 8 characters, 40 bits. Every confusable pair is broken by REMOVING one member (no 0/O, no 1/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/token with grant_type=urn:ietf:params:oauth:grant-type:device_code.

The five error codes the token endpoint returns, and what each means:

CodeMeaningTerminal
authorization_pendingThe human has not answered yetno
slow_downYou polled sooner than interval; the interval has been raisedno
access_deniedThe human said noyes
expired_tokenThe window closedyes
invalid_grantUnknown, replayed, or wrong client_idyes

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 a v1:identity:identity row of identityType="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:

CredentialStored inWhy
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 tokenVS Code SecretStorage, keyed per clusterA 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 expirySecretStorage, beside the refresh tokenRedundant 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 Cluster goes through saveClusterEdit, which writes the entry and then moves the SecretStorage half 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.yaml no 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 bind 127.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 exec against 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 set MEMQL_IDENTITY_ENABLED=false and 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:

shell
kubectl exec -n memql deploy/identity -- \
/app/memql enrolment-token mint --user-email owner@example.com

Open 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:

  1. A passkey does not work across clusters. A credential enrolled against identity.example.com is not offered by the browser at identity.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.
  2. Reinstalling a local cluster orphans its passkeys. A reinstall creates a fresh database, so the v1:identity:identity rows 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 on localhost not 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 call runInstall (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.