# Authentication
URL: https://synquic.tech/docs/security/auth.md

How you prove who you are to Synq. Session cookies with optional two-factor, personal access tokens, MCP OAuth, and opt-in zero-auth loopback mode.

import { TypeTable } from 'fumadocs-ui/components/type-table';

Every request to Synq has to answer one question first: **who are you?** Synq accepts four different ways to answer
it, and which one applies depends on *where* the request comes from: a browser tab (the desktop app counts as
one), a script, an AI agent, or a local single-user instance talking to itself. This page walks through each one.

<Callout title="Base path">
Every API route lives under **`/api`** on your panel, for example `https://app.<domain>/api/projects`. The same
authentication rules apply to all of them; there is no unauthenticated back door. Access checks after sign-in are
covered separately in [Permission model](/docs/security/permissions).
</Callout>

## The four ways in

| Method | What you send | When it applies |
|---|---|---|
| **Session cookie** | An httpOnly cookie the browser holds for you | The dashboard, in a browser or the desktop app |
| **Personal access token (PAT)** | `Authorization: Bearer synq_pat_…` | The CLI, scripts, server-to-server, and MCP clients without OAuth |
| **MCP OAuth** | An OAuth 2.1 access token the client fetches for you | AI agents (Claude, Cursor, …) connecting to `/api/mcp` |
| **Zero-auth loopback** | Nothing: the request just comes from `127.0.0.1` | A local single-user instance you opt into |

A single request uses exactly one of these. Synq checks for a `Bearer` token first; if there isn't one, it looks
for a session cookie; if there's neither, it falls back to the loopback path (and only if that mode is turned on).

## Session cookies (the dashboard)

When you sign in to the dashboard, Synq gives your browser a **signed, httpOnly session cookie**. "httpOnly" means
JavaScript running in the page cannot read it, so even if a bad script somehow ran on the page, it couldn't steal
your session. The browser sends the cookie automatically on every request; you never handle it yourself.

On a Synq install the cookie is named `synq.session_token`. It belongs to the panel's host, so when the panel moves
to a new address everyone signs in again there.

<Callout title="Bearer tokens are refused from the dashboard's own origin" type="warn">
If a request arrives with a `Bearer` header **and** an `Origin` matching one of the dashboard's trusted browser
origins, Synq rejects it (`401 BEARER_NOT_ALLOWED_FROM_BROWSER`). Bearer tokens are for CLIs and servers; a token
showing up from a browser tab is the signature of a stolen credential being replayed, so the door is closed. The CLI
sends no `Origin` header at all, which is why its tokens are accepted.
</Callout>

### Two-factor authentication

Password sign-in can ask for a second step: a 6-digit code from an authenticator app, or one of 10 single-use backup
codes. Each person turns it on for their own account under **Settings**, **Security & 2FA**, and can trust a device
for 30 days. If the owner loses both, `synq reset-admin-password` on the server resets the password and turns
two-factor off. Tokens and MCP clients never ask for a code. Full steps:
[Two-factor authentication](/docs/guides/two-factor).

## Personal access tokens (Bearer)

A **personal access token** (PAT) is a long string you send in the `Authorization` header to prove who you are
without a browser:

```
Authorization: Bearer synq_pat_…
```

Every token looks like `synq_pat_` followed by a 43-character random secret (256 bits of entropy). Synq stores
**only a SHA-256 hash** of it: the plaintext is shown to you exactly once, at creation, and can never be retrieved
again. Tokens created by earlier versions of Synq start with a different prefix and keep working until they expire
or you revoke them. A token always **acts as the person who created it** and carries that person's role, unless you narrow it
(see below).

### Create one

In the dashboard, open **Settings**, **API tokens** and use the **Personal Access Tokens** section:

1. Type a name (for example "MCP client").
2. Pick an expiry: **No expiry**, **30 days**, **90 days** or **1 year**.
3. Optionally tick **Read-only**, and **Limit to specific resources** to choose the resources it can reach.
4. Press **Create token** and copy it: this is the only time it's shown.

The list shows each token with **read-only** and **scoped** badges, when it was last used, when it expires and how
many calls it made, with a **Revoke** button.

From the terminal:

```bash
# A full-access token that acts as you
synq token create "my laptop"

# A read-only token that expires in 90 days
synq token create "ci-readonly" --read-only --expires 90

# A token scoped to one project (read + write only on that project)
synq token create "deploy-bot" --grant project:proj_123:read,write

synq token list      # your tokens (never shows secrets)
synq token revoke <id>
```

<Callout title="Use a token from the CLI" type="info">
`synq login` stores a token so the CLI can send it on every call. Run it with `--token synq_pat_…` for a
non-interactive login (handy in CI), or run it bare to open the token settings page and paste one in. The stored
token is only ever sent as a `Bearer` header, never as a cookie.
</Callout>

The request body behind the create call:

<TypeTable
  type={{
    name: { type: 'string', description: 'Human-readable label for the token.', required: true },
    readOnly: { type: 'boolean', description: 'Reject every mutating method (POST/PUT/PATCH/DELETE).', default: 'false' },
    expiresInDays: { type: 'integer', description: 'Expire after N days (1-365). Omit for a non-expiring token.' },
    grants: { type: 'Grant[]', description: 'Scope the token to specific resources. When present, the token becomes a restricted principal limited to exactly these grants.' },
  }}
/>

### Two ways to narrow a token

- **Read-only**: the token can call read endpoints, but any mutating request is refused with
  `403 TOKEN_READ_ONLY`. Great for dashboards, monitoring, and CI that only needs to look.
- **Scoped**: instead of acting with your full role, the token is confined to a specific list of resources you grant
  it (a project, a server, a repository, …), each with `read`, `write`, or `admin`. A scoped token acts as a
  **restricted principal**: it can do *less* than you, never more. You can only grant access you already hold
  yourself; the server checks this before minting.

<Callout title="A token is tied to one organization" type="info">
Each token is bound to the organization it was created in. If a request carries an `X-Organization-Id` header naming
a different org, it's rejected with `403 TOKEN_ORG_SCOPE`. An invalid or revoked token returns `401 INVALID_TOKEN`.
</Callout>

## MCP OAuth (AI agents)

Synq is a standards-compliant **OAuth 2.1** server for the **Model Context Protocol (MCP)**, the way AI agents call
tools. An MCP-capable client (Claude, Cursor, …) points at `https://app.<domain>/api/mcp`, and the handshake is
automatic: the first unauthenticated request returns a `401` pointing at `/.well-known/oauth-protected-resource`,
the client registers itself, runs the **PKCE** authorize flow (Proof Key for Code Exchange, a way to complete OAuth
safely without a stored client secret), and you approve it on a **consent screen** in your browser.

That consent screen is where you set the agent's limits: **read-only** and/or a specific set of projects, servers
and repositories. Your choices are saved as the client's binding and enforced through the exact same grant model as
a scoped PAT. An OAuth token that somehow skips consent has **no binding, and is therefore denied everything**: there
is no "authenticated but unscoped" state. Tokens are also bound to the endpoint address they were issued for.

You manage connected agents under **Settings**, **MCP**, where you can see each client, change what it can reach
(**Edit access**), see what it did (**Activity**) and disconnect it (which revokes its tokens immediately). Clients
without OAuth support can instead authenticate with a plain PAT as a bearer credential.

<Callout title="Full MCP setup">
Endpoint URLs, client config snippets, and verification steps live on the [MCP page](/docs/mcp).
</Callout>

## Zero-auth loopback mode

An API that runs on your own machine, just for you, can skip the login. In **zero-auth mode** the API auto-provisions a single local admin user (`Local User`,
owner of a personal workspace) and treats local traffic as that user. No password, no cookie to manage.

This is safe only because it is gated, and every gate must pass:

<Callout title="Every condition is required" type="warn">
1. **Not CLI-managed or public.** An instance started from the CLI
   (`synq up` sets `SYNQ_REQUIRE_AUTH`) or served publicly (`--public-url` / `SYNQ_PUBLIC_URL`) refuses zero-auth
   outright: it always requires a login.
2. **Opt-in.** The instance's auth mode must be `none`, and the operator must also set
   `SYNQ_ALLOW_ZERO_AUTH=true` (an API started with `DEPLOY_MODE=desktop`, as desktop app versions up to 1.2.5
   ran on your computer, doesn't need the variable).
3. **Loopback only.** The request must come from a loopback address (`127.0.0.1` / `::1`), read from the
   kernel-reported TCP peer, **not** the `Host` header, which a client or a misconfigured reverse proxy could forge.
   A request from anywhere else is rejected with `401`, even in zero-auth mode.
</Callout>

<Callout title="Why condition 1 exists" type="info">
With `--public-url`, the dashboard's same-origin proxy runs **on the box**, so it reaches the API from loopback,
which would otherwise slip past the loopback gate (condition 3). The explicit CLI/public refusal closes that hole, so
a remote visitor can never land on the zero-auth path.
</Callout>

On a normal self-hosted install this path is never even reached: the auth mode defaults to `local`, so a missing
session is a plain `401`. Turning it on is a deliberate operator choice (auth mode `none` **and**
`SYNQ_ALLOW_ZERO_AUTH=true`), and even then the loopback check keeps it usable only from the machine itself. A
request arriving through a network reverse proxy carries the proxy's address as its TCP peer, not loopback, so it is
refused.

Synq recognizes three auth modes overall:

- **`none`**: zero-auth loopback, described above. Never used by a CLI-started or public instance.
- **`local`**: standard login required (email + password via [Better Auth](https://www.better-auth.com/), with
  optional two-factor). Default for a fresh self-hosted install, and always used for CLI (`synq up`) and public
  instances.
- **`cloud`**: signs in with a hosted-cloud account instead of a local one. It belongs to the hosted cloud, which is
  switched off in Synq, so a Synq install never uses it.

<Callout title="Creating the first admin" type="info">
A CLI or self-hosted instance has no public sign-up. The first admin (email + password) is created at install time
(the installer asks for its email and password) over an
internal-token-gated, one-shot endpoint that a browser can't reach. That account is the **instance administrator**.
After that, add more people from **Settings**, **Users & team** in the dashboard (see
[Teams & members](/docs/guides/teams-members)).
</Callout>

<Callout title="One failure that never falls through" type="error">
If the session machinery itself errors (a database outage, a decryption failure), the request returns
`503 AUTH_UNAVAILABLE`. It does **not** quietly drop to the zero-auth path: a broken session check can never be
mistaken for "no login required."
</Callout>

## Where credentials live

Synq keeps secrets where they're safest instead of copying them around:

- **PAT secrets** are stored only as a SHA-256 hash; the plaintext exists just once, in your clipboard.
- **Two-factor secrets and backup codes** are stored with the account in Synq's database;
  `synq reset-admin-password` deletes them for the owner.
- **GitHub Apps** are registered by each workspace in Settings (see
  [Self-hosted GitHub Apps](/docs/guides/self-hosted-github-app)); each private key, client secret, and webhook secret
  is stored in a versioned AES-256-GCM envelope and is never returned by the API. The legacy process-environment
  configuration remains supported. Every App path mints short-lived, repository-scoped installation tokens.
- Per-project git tokens and environment secrets are encrypted at rest with the install's `BETTER_AUTH_SECRET`. Keep
  that key safe: [panel backups](/docs/guides/panel-backups) can include it for a restore.

## Revocation

- **A token:** `synq token revoke <id>`, or the **Revoke** button in **Settings**, **API tokens**. It stops working on
  its next request.
- **An MCP client:** disconnect it under **Settings**, **MCP**: its issued tokens are revoked and the consent is
  dropped, so reconnecting re-prompts.
- **A person:** removing a member ends their access; their session stops working on its next check.

## Errors you might see

| Status & code | Meaning |
|---|---|
| `401 INVALID_TOKEN` | The bearer token is wrong, expired, or revoked. |
| `401 BEARER_NOT_ALLOWED_FROM_BROWSER` | A bearer token arrived from a dashboard browser origin: send it from a CLI or server instead. |
| `403 TOKEN_READ_ONLY` | A read-only token tried a mutating request. |
| `403 TOKEN_ORG_SCOPE` | The token is bound to a different organization than the one requested. |
| `401 Unauthorized` | No valid session, or a zero-auth request that failed the mode/loopback gates. |
| `503 AUTH_UNAVAILABLE` | The session check itself failed. Retry; it never silently downgrades to no-auth. |

## Related guides

<Cards>
  <Card title="Permission model" href="/docs/security/permissions" description="What a signed-in identity is actually allowed to do: roles and resource grants." />
  <Card title="Two-factor authentication" href="/docs/guides/two-factor" description="Turn on 2FA, keep backup codes, and recover a lost owner login." />
  <Card title="Connect an AI agent (MCP)" href="/docs/mcp" description="Endpoint URLs, client config, and verification for MCP clients." />
</Cards>
