Getting started

Authentication

The six Myna credentials, the CLI device-authorization flow, and which credential each surface expects.

Myna has exactly six credentials, each with a distinct job. Raw secrets are shown once at creation and only SHA-256 hashes are stored at rest; verification is constant-time.

The six credentials

Credential Format Used for
Browser session myna_session cookie The dashboard. Sign-in is GitHub OAuth, optionally accelerated by a passkey — there are no passwords and nothing to reset.
API key myna_sk_<publicId>_<secret> CLI, SDK management client, MCP, CI, agents — all Management API access, plus reads of private collections.
Connector grant myna_mat_<publicId>_<secret> A remote agent authorized through the hosted MCP server. Issued by OAuth, never pasted by hand.
Preview token myna_prv_<publicId>_<secret> Read-only access to unpublished content for a specific preview.
Ingest key myna_ik_<publicId>_<secret> Publishable. Ship it in your browser bundle to collect feedback.
Identity signature X-Myna-Identity: <yourUserId>.<hmacHex> Names which of your users a request is on behalf of. Computed by your server, never a stored credential.

The ingest key is the only credential meant to be public. On its own it can file a report and nothing else — it cannot list reports, read somebody else's, or see an attachment. What makes it safe is origin binding, rate buckets and per-board confinement, not secrecy.

A reporter reading their own report back presents the key and a signature naming them, computed by the integrating application's backend with the key's identity secret. The signature is not a credential Myna issues or stores: it is derived from the customer's own user id on demand, so there is nothing to expire, revoke, or leak from a database. Rotating the identity secret invalidates every signature after a 24-hour grace window.

Both secrets on an ingest key rotate without downtime: the new one takes effect immediately and the old one keeps working for 24 hours, then a durable job hard-revokes it.

A connector grant is not a standalone credential: it names the person who approved it, and its capabilities are recomputed from their live organization role on every request. Reduce their role, or remove them from the organization, and the connection narrows or dies with it — no separate revocation needed. See connecting a remote agent.

Browser sessions

GET /v1/auth/github starts the OAuth dance; the callback sets an HTTP-only session cookie. Sessions are listed and revocable under /v1/account/sessions. Mutating requests carrying a session cookie are origin-checked (CSRF protection) — only the configured app origins are accepted.

Passkeys

A passkey is a faster way into an account that already exists. It is not a second factor: a passkey mints exactly the same session GitHub does, and no route can tell the two apart.

  • GitHub creates the account. You sign in with GitHub first, then add a passkey from account settings.
  • GitHub is the fallback. Lose every passkey and you sign in with GitHub as before. That is why there are no recovery codes and no account-reset path.
  • The GitHub link stays. Publishing through the GitHub App to pinned content needs a GitHub identity, so passkeys never replace the link — they only skip the redirect at sign-in.

Because GitHub remains a valid way in, a passkey raises no security floor; it is a convenience. Protect the GitHub account accordingly.

Route Purpose
POST /v1/auth/passkey/options Start sign-in. Unauthenticated; returns WebAuthn request options.
POST /v1/auth/passkey/verify Finish sign-in. Verifies the assertion and sets the session cookie.
GET /v1/account/passkeys List registered passkeys.
POST /v1/account/passkeys/options Start enrollment for the signed-in user.
POST /v1/account/passkeys/verify Finish enrollment.
PATCH /v1/account/passkeys/:passkey Rename.
DELETE /v1/account/passkeys/:passkey Delete. Refused if it is the account's only remaining way to sign in.

Credentials are discoverable (resident), so sign-in never asks who you are first — the authenticator supplies the account. Challenges are single-use, stored server-side, and expire after five minutes.

Naming

A new passkey is named after the authenticator holding it — iCloud Keychain, Windows Hello, YubiKey 5 Series, 1Password — resolved from the AAGUID the authenticator reports about its own model. Some authenticators withhold that (an all-zero AAGUID), and those fall back to the browser and platform that registered the credential, Chrome on macOS. When neither is available the name records whether the credential is synced or bound to one device.

Every name is a default. Click a name in Account → Passkeys to change it, or pass name when completing registration. The AAGUID list is a snapshot of common authenticators rather than the full FIDO metadata registry, so an unrecognised model falls through to the browser name — it is never an error.

Confirming it is you

Two actions ask you to prove yourself again, even though you are already signed in:

  • approving a CLI device code (myna login)
  • approving an MCP connector on the consent screen

Both hand out a credential that outlives the session that approved it. Revoking a session does not revoke the CLI token or the connector grant it left behind, so a session someone else got hold of would convert into durable access — and for a connector carrying content:publish, into an agent that can publish as you. It is also the answer to device-code phishing, where the whole attack is persuading someone to approve a code they did not start.

A passkey settles it in place if you have one; otherwise you go through GitHub and land back where you were. Either proof lasts five minutes, so approving a CLI login and a connector in the same sitting only asks once. Signing in counts as a proof, so this never fires immediately after login.

Declining is never gated. Refusing access is always safe and must not be the thing a stale session cannot do.

The denial is REAUTHENTICATION_REQUIRED (403), carrying methods — the ceremonies that would satisfy it:

{
  "code": "REAUTHENTICATION_REQUIRED",
  "detail": "This action needs you to confirm it is you.",
  "methods": ["passkey", "github"]
}

GitHub is always offered, which is deliberate. Requiring a passkey only from people who have one would be worth nothing: whoever held the session could delete the passkeys first and walk through the unguarded path. It is also why losing a passkey never locks you out of your own CLI.

Nothing here applies to API keys, preview tokens, or an MCP grant that already exists — those are not browser sessions and there is no one to prompt. Only the human consent moment is gated.

API keys

Keys are minted in the dashboard or via POST /v1/organizations/:organization/api-keys (org-scoped) and POST /v1/projects/:project/api-keys (project-scoped). Every key:

  • carries an explicit, non-empty set of scopes — capabilities are never implied;
  • is either org-scoped (valid across all the organization's projects) or project-scoped (valid for one project only);
  • is returned exactly once at creation ({ "key": ..., "secret": "myna_sk_..." }) — store it immediately, it cannot be retrieved again;
  • is hashed at rest; the embedded public id exists only for lookup;
  • optionally expires (expiresAt) and can be revoked at any time; last use is tracked;
  • may be confined to specific collections.

Send it as a bearer token:

$ curl -H "Authorization: Bearer myna_sk_..." https://api.myna.sh/v1/organizations

Preview tokens

Preview tokens (myna_prv_...) are minted per preview and are strictly read-only: they grant access to the draft content of one change set (optionally one entry) in one project, until they expire or are revoked. They cannot touch the Management API.

CLI device authorization

myna login uses a device-authorization flow, so the CLI never handles your GitHub password:

  1. The CLI calls POST /v1/auth/device and receives { deviceCode, userCode, verificationUri, expiresIn, interval }.
  2. It opens https://app.myna.sh/device?code=<userCode>, where you sign in with GitHub and approve the code (POST /v1/auth/device/:code/approve).
  3. Meanwhile the CLI polls POST /v1/auth/device/:code/token with { "deviceCode": "..." } every interval seconds. The response is { "status": "pending" } until approval, then { "status": "approved", "token": "myna_sk_..." } — delivered exactly once — or { "status": "expired" } if the code timed out.

Approval mints an org-scoped API key labeled cli for your organization. The CLI stores it in the macOS Keychain when available, otherwise in a 0600 config file.

$ myna login
# Opens app.myna.sh/device — approve, and the CLI picks up the token.

Non-blocking device authorization

myna login blocks until someone approves in a browser. For automated callers, the same flow is available in two halves:

$ myna login start --json
{"version":1,"data":{"deviceCode":"…","userCode":"9BJ6-4LGE","verificationUri":"https://app.myna.sh/device?code=9BJ6-4LGE","expiresIn":600,"interval":5}}

$ myna login poll <device-code> --json
{"version":1,"data":{"status":"pending","stored":false}}

login start returns immediately with the code and verification URL. login poll checks once and exits, or waits with --wait --interval <seconds> --timeout <seconds>.

status is one of pending, approved, denied, expired, or timeout. On approved, the credential is stored exactly as myna login stores it. A denied or expired authorization is returned as a result with a non-zero exit code, so branch on status rather than on the process failing.

Verifying a credential

A stored credential is not necessarily a working one — an expired or revoked key remains on disk while every call returns 401. Myna reports presence and validity as separate facts.

$ myna auth verify --json
{"version":1,"data":{"credentialPresent":true,"credentialValid":false,"source":"stored","identity":null,
                     "error":{"code":"AUTHENTICATION_REQUIRED","detail":"Authentication required"}}}
Command Network call Reports
myna status No Local configuration and credentialPresent only. Never claims the credential works.
myna auth verify Yes credentialPresent and credentialValid as separate facts. Always exits 0 — the answer is the payload.
myna whoami Yes The same, plus the resolved identity and organization. Exits non-zero when the credential is invalid.
myna doctor Yes All of the above plus version, API compatibility, project, schema, types, and origin checks.
myna access Yes What this credential may do in one project, and why a specific call would be denied. Exits non-zero when it would be.

GET /v1/auth/whoami identifies the credential behind a request. For API keys it returns the key's scopes and label, which distinguishes a missing scope from a missing resource — both otherwise surface as a denial.

Why a call was denied

A 403 and a 404 are the only two answers Myna can safely give at the moment of a call, and neither says which of three unrelated things is wrong. GET /v1/projects/:project/access says:

$ myna access --capability content:publish --collection posts
Project        blog
Credential     api_key
Capabilities   assets:read, assets:write, content:read, content:write
Collections    posts, guides
  ok   credential — Credential is an API key with 4 scope(s).
  ok   project — Project blog resolves and is reachable with this credential.
  fail capability — Credential does not carry content:publish. It has: content:read, content:write, assets:read, assets:write.
  ok   collection — Collection "posts" exists and is within reach.

  remedy: Mint a key that carries content:publish: myna keys create --scopes content:publish

?capability= and ?collection= are both optional; without them the response is just what the credential can do here. The checks array separates the causes:

Check Fails when
credential A preview token was used for a management call. It authorizes reading one change set's rendered content and nothing else.
project Never, once reached — an unreachable project answers 404 at the route, which is itself the diagnosis.
capability The credential does not hold it. The detail says whether the scope was never granted, or was granted and then narrowed by the role it acts under.
collection The credential is confined to other collections, or the collection does not exist in this project — which is a missing resource, not a permission.

Reaching this endpoint needs only a credential that already reaches the project; everything it reports is about the caller's own access. Anonymous requests are refused rather than answered, so it is not a way to probe which project slugs exist. Agents should call myna_explain_denial once after a denial rather than retrying — see for agents.

Environment variables

The CLI and MCP server resolve configuration as explicit flag > environment > linked project (.myna/project.json) > user config:

Variable Meaning
MYNA_TOKEN Bearer credential (myna_sk_...). Overrides stored credentials — the natural choice for CI and agents.
MYNA_API_URL API origin. Defaults to https://api.myna.sh.
MYNA_PROJECT Default project id or slug.
MYNA_ORGANIZATION Default organization id or slug.

Which credential goes where

Surface Credential
Dashboard Browser session (GitHub OAuth, or a passkey once enrolled)
SDK public client — createMyna({ project, apiKey? }) None for public collections; a content:read key for private ones; optional previewToken
SDK management client — createManagementClient({ token }) myna_sk_... key
CLI Token from myna login, or MYNA_TOKEN
Local MCP server MYNA_TOKEN (or a per-project token in mcp.json)
Hosted MCP server OAuth connector grant, or a myna_sk_... key as a bearer
Preview fetches myna_prv_... token

Scoping keys for agents

Give agents the narrowest key that does the job: project-scoped, with only the scopes the task needs (content:read + content:write for drafting; add content:publish only if the agent may publish), and an expiry. Keep project:admin and org-scoped keys for humans and CI that provision infrastructure. A leaked narrow key is an inconvenience; a leaked admin key is an incident.