# 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](#passkeys) — there are no passwords and nothing to reset. | | API key | `myna_sk__` | CLI, SDK management client, MCP, CI, agents — all Management API access, plus reads of private collections. | | Connector grant | `myna_mat__` | A remote agent authorized through the [hosted MCP server](/mcp/hosted). Issued by OAuth, never pasted by hand. | | Preview token | `myna_prv__` | Read-only access to unpublished content for a specific [preview](/concepts/previews). | | Ingest key | `myna_ik__` | Publishable. Ship it in your browser bundle to [collect feedback](/concepts/collecting-feedback). | | Identity signature | `X-Myna-Identity: .` | 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](/mcp/hosted). ### 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](/concepts/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: ```json { "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](/platform/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: ```bash $ curl -H "Authorization: Bearer myna_sk_..." https://api.myna.sh/v1/organizations ``` ### Preview tokens Preview tokens (`myna_prv_...`) are minted per [preview](/concepts/previews) 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=`, 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. ```bash $ 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: ```bash $ 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 --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 --timeout `. `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. ```bash $ 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: ```bash $ 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](/platform/scopes), 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](/agents/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](/platform/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.