Authentication

Sign in to the dashboard, and protect remote API access with scoped tokens.

Pepe has two front doors and each has its own lock. The dashboard is for people, and it is guarded by an optional password plus a network rule that fails closed. The /v1 HTTP API is for programs, and it is guarded by bearer tokens that carry a scope. On your own machine neither lock is in your way, and neither door opens to the network until you turn its lock on.

Dashboard authentication

The dashboard is open by default so a local install has zero friction: run pepe serve on your own machine and browse to it. Authentication is opt-in: the moment you set a dashboard password, every page requires signing in. There is no database and no user table. The password is checked in constant time and a signed flag rides in the Phoenix session cookie.

Enabling it

Set a password either way. If both are present, the config value wins:

# Option A: an environment variable, so nothing lands in the config file.
export PEPE_DASHBOARD_PASSWORD='a long passphrase'

# Option B: store a reference, so the secret still comes from the environment.
pepe dashboard password '${PEPE_DASHBOARD_PASSWORD}'

# Check the current state, or turn it off again.
pepe dashboard
pepe dashboard password --clear

The value is ${ENV}-interpolated at read time, so, like every other secret in Pepe, it is never written to ~/.pepe/config.json in the clear.

With a password set:

Unset the password, by removing the environment variable or the config key, and the dashboard is open again.

Fail closed: the dashboard is never open to the network without a password

Being “open by default” is safe only because that default is loopback-only. A per-request guard enforces it: with no password set, the dashboard answers only genuine localhost clients. Any request from somewhere else, whether a LAN address, a virtual machine, or a reverse proxy, gets a 403 telling you to set a password. There is no “allow open anyway” switch: reaching the dashboard from off the box means either a password or a tunnel.

The rule, precisely:

Request comes from No password Password set
localhost (loopback, no proxy headers) allowed login required
LAN, a VM, or another machine 403 login required
through a proxy (X-Forwarded-For present) 403 login required

LAN and private ranges (192.168.x, 10.x, 172.16.x) count as public, not as trusted. The /v1 API and the /webhooks endpoints are unaffected by this rule; they carry their own authentication, described below.

Reaching it from another machine

Two options are safe:

  1. Set a password and expose the dashboard behind TLS, using a reverse proxy or a tunnel, so the password and the session cookie are never sent in the clear. When you put a proxy in front, keep the password on, because a proxied request is treated as public.

  2. Keep it on loopback and tunnel in, so nothing is opened to the network at all:

pepe serve --tunnel                     # built-in Cloudflare quick tunnel (needs cloudflared)
ssh -L 4000:localhost:4000 you@server   # then browse http://localhost:4000
tailscale serve 4000                    # a private tailnet, no public port

pepe serve --tunnel runs cloudflared and prints a public https://<...>.trycloudflare.com URL for the life of the process. Because the tunnel is a proxy, a tunneled request counts as public, so set a dashboard password before using it. The full walkthrough, including named tunnels with a stable URL you choose, is on the Dashboard page.

ssh -L and a Multipass port-forward instead arrive on loopback, so they just work with no password. A VM reached across its virtual network looks remote and is blocked, so forward its port to localhost.

Serving behind a domain or a reverse proxy

Two optional settings make a real deployment behave correctly:

# The Host header values the dashboard should answer to (loopback names always work).
pepe dashboard hosts dash.example.com

# The reverse proxies whose X-Forwarded-For may be trusted (CIDRs or bare IPs).
pepe dashboard trusted-proxies 127.0.0.1,10.0.0.0/8

# Show the current posture: authentication, hosts, proxies.
pepe dashboard

Brute-force protection

POST /login is rate-limited per client IP, by default 10 attempts every 60 seconds, and a successful login resets the counter. That sits on top of the constant-time password compare and a small delay on each failure. Going over the limit returns 429 with a Retry-After header.

Extending it

The gate is deliberately small and composable: one on_mount hook (PepeWeb.Auth), one plug (PepeWeb.NetworkGuard, backed by Pepe.Net and PepeWeb.RemoteClient), and the login throttle. Richer schemes, such as OAuth, trusted-proxy identity headers, or per-operator accounts, can slot in without touching each LiveView.

Authentication and tokens

With zero tokens configured, the API answers only same-machine (loopback) callers. A local curl or the dashboard works with no token, but any remote caller is refused with 401, so a server you expose on a network is never anonymous.

Creating the first token flips the switch for everyone. Once any token exists, every request, local or remote, must present a valid one or it is refused with 401. Minting the first token is what unlocks remote access.

Minting and managing tokens

You can mint, list, and revoke tokens three ways: the CLI, the dashboard, or by chat.

From the CLI:

pepe token add [--project PROJECT] [--agent HANDLE] [--label "..."]
pepe token list
pepe token revoke ID

In the dashboard, the API tokens page has a form to generate a token (with a project and optional agent scope) and a list to revoke existing ones.

A token is a random string prefixed pepe_. Only its SHA-256 hash is stored in the config file; the raw token is printed once at creation and never again. Copy it then. If you lose it, revoke it and mint a new one.

Do it by chat

An agent granted the guarded manage_token tool can mint, list, and revoke tokens from a conversation. Because a token grants API access, the tool is not read-only: it goes through the permission gate, so you confirm before a token is created, and the raw secret is returned once for you to copy.

You: Create a token for the acme project, labeled chatwoot.

Agent: (asks you to confirm, then mints it) API token created, scope project acme. Copy it now, it will not be shown again: pepe_9f2a...

Presenting a token

Send it either way an OpenAI-style client would:

# OpenAI standard: Authorization: Bearer
curl http://localhost:4000/v1/chat/completions \
  -H 'authorization: Bearer pepe_your_token_here' \
  -H 'content-type: application/json' \
  -d '{ "model": "assistant", "messages": [{"role":"user","content":"hi"}] }'
# Azure OpenAI style: api-key header (accepted as a fallback)
curl http://localhost:4000/v1/chat/completions \
  -H 'api-key: pepe_your_token_here' \
  -H 'content-type: application/json' \
  -d '{ "model": "assistant", "messages": [{"role":"user","content":"hi"}] }'

Any OpenAI SDK sends the Authorization: Bearer form when you set its api_key, so authentication needs no special handling on the client.

Token scopes

A token carries a scope that decides which agents it can reach. From narrowest to widest:

Token permissions

The scope says whose data a token reaches. A separate set of permissions says what it may do with it. The defaults leave every token you have already minted exactly as it was: it may run agents, and it may not read usage.

Flag Default What it grants
--chat / --no-chat on run agents (/v1/chat/completions, the WebSocket)
--usage off read /v1/usage, the billing figures
--prices billable how much of the money a usage read shows
--content off a run’s detail may include the prompt and tool arguments/output
# a read-only billing token for a client: reads the figures, cannot spend your budget
pepe token add --project acme --no-chat --usage --prices billable

pepe token permissions abc123 --prices list    # change in place, secret untouched

The two halves are independent on purpose. Without them, giving a client visibility into their own spend meant giving them a credential that could also run agents on your account. See the Usage API for what those reads return.

What each scope sees in GET /v1/models

Token Returns
--project acme only acme agents
--project globex only globex agents
--agent acme/support only that one agent
default project (no flag) the default project’s agents plus raw model connections
no token (loopback only) every agent, all projects, plus raw model connections

A token never crosses the boundary: an acme token can never list or reach a globex agent. There is no token that names another project to read it. To get another project’s agents, mint that project’s own token. For a cross-project operator view, use the CLI (pepe agent list) or the dashboard, not a tenant token.

Multi-tenant routing: give project X its own access

Scopes are how you hand out API access per tenant. To give a project its own key, mint a project-scoped token:

pepe token add --project acme --label "Acme production"
# prints: pepe_9f2a... (copy it now, shown once)

A caller holding that token:

# Allowed: an agent inside acme.
curl http://localhost:4000/v1/chat/completions \
  -H 'authorization: Bearer pepe_9f2a...' \
  -H 'content-type: application/json' \
  -d '{ "model": "support", "messages": [{"role":"user","content":"hi"}] }'

# Refused with 403: an agent outside acme.
curl http://localhost:4000/v1/chat/completions \
  -H 'authorization: Bearer pepe_9f2a...' \
  -H 'content-type: application/json' \
  -d '{ "model": "some-other-project-agent", "messages": [{"role":"user","content":"hi"}] }'

To pin a token to exactly one agent (the model field is then ignored entirely), add --agent:

pepe token add --project acme --agent acme/support --label "Acme support widget"