Skip to content

API authentication

The ProxiPort REST API supports three credential flows for human users and one for programmatic clients. They share a backing user store: a single inline credential, a JSON file, or a database table.

This page covers how the API authenticates an HTTP request, how to enable a TOTP second factor, and how to delegate authentication to a reverse proxy. For agent (chisel) authentication see client authentication.

Authentication flows

HTTP basic with username + password

Every endpoint accepts a Basic Authorization header. The server checks the credentials on each request:

curl -s -u admin:password \
  https://proxiport.example.com/api/v1/clients | jq

When the TOTP second factor is enabled, basic auth with a password stops working on every endpoint except /login. The workaround for script integrations is the personal API token below.

JWT bearer token

GET /api/v1/login with basic auth returns a short-lived JWT. The default lifetime is 10 minutes; request a longer one with ?token-lifetime=<seconds>. The SvelteKit SPA asks for 24 hours.

TOKEN=$(curl -s -u admin:password \
  'https://proxiport.example.com/api/v1/login?token-lifetime=3600' \
  | jq -r .data.token)

curl -s -H "Authorization: Bearer $TOKEN" \
  https://proxiport.example.com/api/v1/clients | jq

The JWT is HMAC-signed with [api] jwt_secret from proxiportd.conf. Pin a long random value before exposing the API — if the secret is left unset, the server generates a fresh one at every restart and every existing session is invalidated.

Login tokens are not bearer tokens

When TOTP is enabled, /login returns an intermediate token that is only accepted by /verify-2fa. The final bearer token comes back from /verify-2fa once the TOTP code is validated.

Personal API token

Each user can mint named, scoped API tokens. They authenticate via HTTP basic with the username + token in place of the password, so they work even when TOTP is enabled on the account:

The token is the <prefix>_<secret> string returned by POST /api/v1/me/tokens — use it verbatim (the leading prefix_ is required; a bare secret without it does not authenticate):

curl -s -u admin:Ab3xK9pq_e83d40e4-e237-43d6-bb99-35972ded631b \
  https://proxiport.example.com/api/v1/clients | jq

Tokens carry an expiry date and a scope (read, read+write). Mint them from Settings → API Tokens in the SPA, or via POST /api/v1/me/tokens. List them with GET /api/v1/me/tokens and revoke one with DELETE /api/v1/me/tokens/{prefix} — the underlying JWT becomes unverifiable immediately.

User stores

Exactly one user store is active at a time. Combining the three modes is rejected at startup.

Inline single user

The simplest setup: pin one credential in proxiportd.conf:

[api]
  auth = "admin:supersecret"

This mode has no SPA-managed user list, no multi-user support, and no TOTP. It is useful for an initial install or a single-operator deployment. Move to the JSON file or the database before sharing the server.

JSON user file

Point [api] auth_file at a writable JSON file:

[api]
  auth_file = "/var/lib/proxiport/api-auth.json"

The file is a list of users with bcrypt-hashed passwords:

[
  {
    "username": "alice",
    "password": "$2y$10$ezwCZekHE/qxMb4g9n6rU.XIIdCnHnOo.q2wqqA8LyYf3ihonenmu",
    "groups": ["Administrators"]
  },
  {
    "username": "bob",
    "password": "$2y$10$ezwCZekHE/qxMb4g9n6rU.XIIdCnHnOo.q2wqqA8LyYf3ihonenmu",
    "groups": ["operators"],
    "two_fa_send_to": "bob@example.com",
    "totp_secret": ""
  }
]

Generate bcrypt hashes with htpasswd -nbB '' 'your-password' | cut -d: -f2. The file is read on start and on kill -SIGUSR1 <pid> — edit it and reload, or use the SPA to manage users (which writes the file in place).

The server needs read+write access to the file when the SPA is the source of truth, so chown proxiport /var/lib/proxiport/api-auth.json after creation.

Database

To integrate with an existing identity store or to manage thousands of users efficiently, point [api] at a set of database tables and configure [database] with the connection:

[database]
  db_type = "sqlite"
  db_name = "/var/lib/proxiport/database.sqlite3"

[api]
  auth_user_table = "users"
  auth_group_table = "groups"
  auth_group_details_table = "group_details"

The schema for SQLite:

CREATE TABLE users (
  username TEXT NOT NULL,
  password TEXT NOT NULL,
  password_expired BOOLEAN NOT NULL DEFAULT 0,
  token TEXT DEFAULT NULL,
  two_fa_send_to TEXT,
  totp_secret TEXT
);
CREATE UNIQUE INDEX users_username ON users (username);

CREATE TABLE groups (
  username TEXT NOT NULL,
  "group" TEXT NOT NULL
);
CREATE UNIQUE INDEX groups_username_group ON groups (username, "group");

CREATE TABLE group_details (
  name TEXT NOT NULL,
  permissions TEXT DEFAULT '{}',
  tunnels_restricted TEXT DEFAULT '{}',
  commands_restricted TEXT DEFAULT '{}'
);
CREATE UNIQUE INDEX group_details_name ON group_details (name);

The MySQL equivalents use VARCHAR and InnoDB; see proxiportd.example.conf for the [database] connection options.

Seed the first user:

INSERT INTO users (username, password)
  VALUES ('admin', '$2y$05$zfvuP4PvjsNWTqRFLdswEeRzETE2KiZONJQyVn7T3ZV5qcYAlmNWO');
INSERT INTO groups (username, "group") VALUES ('admin', 'Administrators');

Managing users in the SPA

Settings → Users and Settings → User groups manage the user store from the web UI, backed by the same POST/PUT/DELETE /users and PUT/DELETE /user-groups endpoints. What you can do depends on which store is active:

Store Add / edit / delete users Edit group permissions
Inline single user
JSON user file Yes — (every user is a full admin)
Database Yes Yes

On the inline single-user store both pages are read-only: the API rejects user and group writes, and the SPA shows the current account alongside a note explaining why. Switch to the JSON file or the database (above) to manage more than one operator.

Users

Settings → Users lists every account. New user and Edit set the username, password, and group membership; Delete removes the account; and — when TOTP authenticator 2FA is enabled — Reset 2FA clears a user's enrolled TOTP secret so they re-enroll on next login (out-of-band email/push 2FA has no per-user secret to reset). Tick a group to assign it, or type a new name to create one on the spot. Passwords must meet the server's minimum length, and Require password change forces a reset at next login. Out-of-band 2FA (email/push) also needs a delivery address per user.

User groups and permissions

A user's permissions are the union of the permissions of its groups — there are no per-user grants. Each group toggles eight areas: tunnels, commands, scripts, scheduler, monitoring, auditlog, uploads, and vault. The built-in Administrators group grants all eight and cannot be edited.

Settings → User groups shows each group's permissions. In the database store the checkboxes are editable and Save writes them back; in the JSON-file store they are read-only, because that store grants every user the full permission set. Create a group by assigning its name to a user on the Users page, then set what it grants here.

Group-permission editing needs the database store

Fine-grained per-group permissions require the auth_group_details_table. The JSON file has no place to store them, so file-store deployments treat every user as an administrator.

Two-factor authentication

ProxiPort supports two second-factor flows. Both require the JSON file or the database — the inline single-user mode cannot enable 2FA.

TOTP authenticator app

The recommended setup. Set [api] totp_enabled = true and restart. Each user is prompted to enroll on next login — the SPA renders a QR code; scan it with any RFC 6238 app (Aegis, Google Authenticator, 1Password, etc.). The secret stays in the database; the QR is rendered client-side.

When users live in the database (auth_user_table) and a [key_provider] is configured, the totp_secret column is encrypted at rest under the server DEK (enc:v1:… on disk), and any existing plaintext secret is re-encrypted on the next boot. Secrets stored via the JSON auth_file are not covered by this — that file is read as-is.

[api]
  totp_enabled = true
  totp_login_session_ttl = "600s"
  totp_account_name = "ProxiPort"

Run multiple servers with distinct totp_account_name values so the authenticator app can tell them apart.

To enroll programmatically:

# Step 1: get a login token (cannot be used as a bearer token).
LOGIN_TOKEN=$(curl -s -u alice:password \
  https://proxiport.example.com/api/v1/login | jq -r .data.token)

# Step 2: create a TOTP secret (returns secret + base64 PNG of QR).
curl -s -X POST \
  -H "Authorization: Bearer $LOGIN_TOKEN" \
  https://proxiport.example.com/api/v1/me/totp-secret

# Step 3: validate a code from the app to finish enrollment.
curl -s -X POST \
  https://proxiport.example.com/api/v1/verify-2fa \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $LOGIN_TOKEN" \
  --data-raw '{"username":"alice","token":"123456"}'

The secret is returned once

Step 2 is the only response that carries the shared secret and the QR image. GET /api/v1/me/totp-secret reports enrollment status only — {"enrolled": true} — so a leaked session or a read-scoped API token cannot lift the secret and keep minting codes after the session is revoked. Capture the secret at enrollment time, or reset the factor and enroll again.

Removing your own factor with DELETE /api/v1/me/totp-secret requires the account password in the request body:

curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-raw '{"old_password":"password"}' \
  https://proxiport.example.com/api/v1/me/totp-secret

Changing your own two_fa_send_to delivery address takes the same old_password step-up, for the same reason: both are one step away from moving the second factor to an attacker's device.

Admins can reset a user's TOTP from Users in the SPA, or by DELETE /api/v1/users/<username>/totp-secret.

Out-of-band code delivery

For sites that prefer email or push, set two_fa_token_delivery to smtp, pushover, a URL, or the path to an executable. The user's two_fa_send_to field carries the destination address:

[api]
  two_fa_token_delivery = "smtp"
  two_fa_token_ttl_seconds = 600

Configure [smtp] or [pushover] to match. The flow mirrors TOTP: /login returns a login token, the server sends a code, and the user posts {username, token} to /verify-2fa to get the final JWT.

Login throttling and second-factor limits

Failed authentication is rate-limited on two axes. Both are on by default; neither needs configuring to be effective.

Per-IP. [api] max_failed_login consecutive failures from one source address ban that address for ban_time seconds. A successful authentication clears the counter for that address.

Per-account. A failed login also bans the IP and username pair for ban_time, so spraying one password across many accounts from a single host does not stay under a purely per-account limit, and one attacker cannot lock out a legitimate user by burning that user's counter from elsewhere.

[api]
  max_failed_login = 10
  ban_time = 600

Second factors carry their own limits, independent of the settings above:

  • A pending login accepts at most five wrong second-factor codes. On the fifth the pending session is discarded and the user starts over from /login with their password.
  • A TOTP code is accepted once. The ±1-step skew keeps a code usable for about 90 seconds, so a code that was shoulder-surfed or phished within that window would otherwise still work a second time; the server records the step it accepted and refuses that step and any earlier one.
  • Out-of-band codes are not re-sent while the outstanding one still has more than 30 seconds to live. Repeating /login returns the same delivery target instead of mailing or pushing another code, so the endpoint cannot be used to flood a user's inbox or phone.

Sessions are invalidated when the password behind them changes — whether the user changes it themselves, an admin changes it from Users, or it is changed during login. Anyone still holding a bearer token minted under the old password is logged out.

Changing a password at login with 2FA on

/login accepts a new_password alongside the current one. When a second factor is enabled the new password is held and applied only after the factor is validated, so knowledge of the current password alone cannot change it.

Delegated authentication

The server can treat any request that arrives with a configured header as pre-authenticated. The reverse proxy decides whether to allow the request; ProxiPort takes the username from a header value and issues a JWT against the matching user record.

[api]
  auth_header = "Authentication-IsAuthenticated"
  user_header = "Authentication-User"
  create_missing_users = true
  default_user_group = "operators"

The pre-auth flow still goes through /login, so the proxy needs to inject the header on that endpoint. Once the SPA holds a JWT, every subsequent call uses the bearer token and the headers are ignored.

Lock down the trust boundary

Anyone who can set auth_header on a request to the API server can impersonate any user. Bind ProxiPort to localhost (or to a dedicated network interface only the reverse proxy can reach) and refuse the header at the public edge.

Command-line user management

The proxiportd user subcommand writes directly to whichever store [api] points at, bypassing the API. Use it for break-glass password resets when no admin can log in:

sudo -u proxiport proxiportd user change -u alice -p \
  -c /etc/proxiport/proxiportd.conf

Run it as the proxiport system user, not root, or the JSON file's permissions will end up unreadable by the daemon.

Hardening checklist

  • Pin [api] jwt_secret to a long random value, and store it encrypted — anyone who reads it in the clear can forge admin sessions. See encrypting the config secrets.
  • Switch off the inline single-user mode as soon as you have more than one operator.
  • Enable totp_enabled = true if you can.
  • Leave max_failed_login and ban_time at their defaults or tighter — see login throttling.
  • Sit the API behind TLS — see HTTPS.
  • Restrict the username/password basic-auth flow at the reverse proxy if you only intend to allow bearer tokens.
  • Audit access from Audit log in the SPA; every state-changing call is recorded.

See also: operator runbook — rotating credentials.