Skip to content

Security: turnstonelabs/turnstone

Security

docs/security.md

Security and Authentication

Turnstone uses a layered authentication system with three token types, hierarchical scopes, and a split architecture where the console manages credentials while individual server nodes validate JWTs locally.


Token Types

Config-file tokens

Static tokens defined in config.toml or the TURNSTONE_AUTH_TOKEN environment variable. Validated in-memory using hmac.compare_digest (timing-safe). Each token maps to a role that determines its scopes.

[[auth.tokens]]
value = "tok_legacy"
role = "full"     # full → {read, write, approve}

Role mappings: "read"{read}, "full"{read, write, approve}.

Config tokens are sent directly as Authorization: Bearer tok_legacy on every request. No JWT exchange is needed.

API tokens

Database-backed tokens prefixed with ts_. Created via the admin CLI (turnstone-admin create-token) or the console admin API. Stored as SHA-256 hashes — the raw token is shown exactly once at creation and never persisted in plaintext.

$ turnstone-admin create-token --user abc123 --scopes read,write --name "CI bot"
Token created: ts_a1b2c3d4e5f6...
(save this — it will not be shown again)

API tokens can be used directly as Bearer ts_xxx headers or exchanged for a JWT via the login endpoint.

JWTs

Short-lived session tokens (24 hours by default). Issued after authenticating with username/password or by exchanging an API token. HS256-signed with a shared secret. Validated locally on every service node — no database call per request.

Claims:

Claim Description
sub User ID
scopes Comma-separated scope list (read,write,approve)
src Token source (password, api_token, config)
iat Issued-at timestamp
exp Expiry timestamp

Scope Model

Scopes are hierarchical — higher scopes imply all lower ones.

Scope Grants Implies
read View workstreams, sessions, history
write Send messages, create/close workstreams read
approve Approve tool calls, admin endpoints read, write

Path-to-scope mapping

Method Path pattern Required scope
GET Any protected path read
POST /api/send, /api/plan, /api/command write
POST /api/workstreams/new, /api/workstreams/close write
POST /api/cluster/workstreams/new write
POST /api/approve approve
Any /api/admin/* approve

Public paths bypass authentication entirely: /, /health, /metrics, /static/*, /shared/*, /docs, /openapi.json, /api/auth/login, /api/auth/logout, /api/auth/status, /api/auth/setup.


Login Flows

Username and password

POST /v1/api/auth/login
Content-Type: application/json

{"username": "admin", "password": "s3cret"}

Returns a JWT in the response body and sets an HttpOnly session cookie.

API token exchange

POST /v1/api/auth/login
Content-Type: application/json

{"token": "ts_a1b2c3d4e5f6..."}

The API token is hashed, looked up in the database, and exchanged for a JWT with the token's scopes. This is the recommended flow for SDKs and automated clients that need cookie-based sessions.

Config-file tokens (direct)

Config tokens are validated per-request via hmac.compare_digest. No login exchange is needed — include the token as a Bearer header:

Authorization: Bearer tok_legacy

First-time setup

When no users exist in the database:

  1. GET /v1/api/auth/status returns {"setup_required": true}
  2. The UI presents a setup wizard
  3. POST /v1/api/auth/setup creates the first admin user and returns a JWT in one atomic step (no auth required — this is a public endpoint)
  4. The endpoint returns 409 Conflict if setup has already been completed (i.e. users already exist in the database)
  5. Subsequent admin requests require approve scope

The /api/auth/setup endpoint is available on both the server and console. It validates input before creating the user:

  • username: 1-64 ASCII characters
  • display_name: required (non-empty)
  • password: minimum 8 characters
POST /v1/api/auth/setup
Content-Type: application/json

{"username": "admin", "display_name": "Admin", "password": "strongpass"}

Response:

{
  "status": "ok",
  "user_id": "u_abc123",
  "username": "admin",
  "role": "full",
  "scopes": "approve,read,write",
  "jwt": "eyJhbGciOiJIUzI1NiIs..."
}

The response also sets an HttpOnly session cookie containing the JWT, so the browser is immediately authenticated after setup completes.


Token Detection Order

The auth middleware inspects the Authorization: Bearer <token> header and classifies the token:

  1. Contains . → JWT → validate HS256 signature and expiry
  2. Starts with ts_ → API token → SHA-256 hash, database lookup
  3. Otherwise → config-file token → hmac.compare_digest against each configured token

If a session cookie is present and no Authorization header is sent, the cookie value is treated as a JWT (step 1).


Password Storage

Passwords are hashed with bcrypt using a random salt per password. Plaintext passwords are only accepted over HTTPS in production deployments.


Cookie Security

Attribute Value Purpose
HttpOnly true Prevents JavaScript access
SameSite Lax CSRF protection
Path / Available to all routes
Max-Age 30 days Session lifetime
Secure conditional Set when served over HTTPS

JWT Configuration

Setting Config key Env var Default
Signing secret [auth] jwt_secret TURNSTONE_JWT_SECRET Auto-generated ephemeral (warning logged)
Expiry [auth] jwt_expiry_hours 24 hours
Algorithm HS256 (not configurable)

All service nodes that need to validate JWTs must share the same signing secret. If no secret is configured, an ephemeral key is generated at startup and a warning is logged — JWTs will not survive restarts or work across nodes.


Admin API Endpoints

All admin endpoints require approve scope.

Users

Method Path Description
POST /v1/api/admin/users Create user (username, display_name, password)
GET /v1/api/admin/users List all users
DELETE /v1/api/admin/users/{user_id} Delete user and cascade tokens

API tokens

Method Path Description
POST /v1/api/admin/users/{user_id}/tokens Create API token (returns raw value once)
GET /v1/api/admin/users/{user_id}/tokens List tokens (prefix only, no hashes)
DELETE /v1/api/admin/tokens/{token_id} Revoke token

CLI Administration

The turnstone-admin command provides offline user and token management:

turnstone-admin create-user --username admin --name "Admin" [--password] [--token]
turnstone-admin create-token --user <user_id> --scopes read,write --name "CI bot"
turnstone-admin list-users
turnstone-admin list-tokens
turnstone-admin revoke-token <token_id>

When --password is omitted, the CLI prompts interactively. When --token is passed to create-user, an API token is created alongside the user and printed to stdout.


Database Schema

CREATE TABLE users (
    user_id    TEXT PRIMARY KEY,
    username   TEXT NOT NULL UNIQUE,
    display_name TEXT NOT NULL,
    password_hash TEXT NOT NULL,
    created    TEXT NOT NULL
);

CREATE TABLE api_tokens (
    token_id     TEXT PRIMARY KEY,
    token_hash   TEXT NOT NULL,       -- SHA-256 of raw token
    token_prefix TEXT NOT NULL,       -- first 8 chars for display
    user_id      TEXT NOT NULL REFERENCES users(user_id),
    name         TEXT NOT NULL,
    scopes       TEXT NOT NULL,       -- comma-separated
    created      TEXT NOT NULL,
    expires      TEXT                 -- nullable, ISO 8601
);

CREATE UNIQUE INDEX ix_api_tokens_hash ON api_tokens(token_hash);

CREATE TABLE channel_users (
    channel_type    TEXT NOT NULL,
    channel_user_id TEXT NOT NULL,
    user_id         TEXT NOT NULL REFERENCES users(user_id),
    created         TEXT NOT NULL,
    PRIMARY KEY (channel_type, channel_user_id)
);

The sessions and workstreams tables have a nullable user_id column for attribution when auth is enabled.


Revocation

  • API tokens: Deleting a token via the admin API or CLI prevents new JWTs from being issued with that token. Existing JWTs derived from the token remain valid until they expire (at most 24 hours).
  • Config-file tokens: Remove the token from config.toml and restart the service. No JWTs are involved, so revocation is immediate.
  • JWTs: Cannot be individually revoked. Rely on short expiry (24h) and revoke the underlying credential to prevent renewal.

Architecture

Console (cluster-wide)              Server (per-node)
┌──────────────────────┐           ┌──────────────────────┐
│ User/Token CRUD (DB) │           │ JWT validation only  │
│ Login: creds → JWT   │           │ (shared signing key) │
│ Admin API endpoints  │           │ Config tokens: hmac  │
│ Storage: users,      │           │ No auth DB needed    │
│   api_tokens tables  │           │                      │
└──────────────────────┘           └──────────────────────┘

The console owns the credential database and handles all user/token CRUD. Individual server nodes only need the JWT signing secret to validate session tokens. Config-file tokens are validated locally without any database.

Proxy auth forwarding

When the console proxies requests to server nodes (via /node/{id}/... routes), it extracts the user's JWT from the incoming request's cookie or Authorization header and forwards it as Authorization: Bearer to the upstream server. This means a single login on the console grants access to all server UIs without re-authentication — the shared TURNSTONE_JWT_SECRET ensures tokens are valid on every node.


Configuration Reference

config.toml

[auth]
enabled = true
jwt_secret = "your-secret-key-here"
jwt_expiry_hours = 24

[[auth.tokens]]
value = "tok_legacy"
role = "full"

Environment variables

Variable Description
TURNSTONE_AUTH_ENABLED=1 Enable authentication
TURNSTONE_AUTH_TOKEN=tok_xxx Register a config-file token with full access
TURNSTONE_JWT_SECRET=xxx JWT signing secret (must match across nodes)

Security Properties

  • Timing-safe comparison for config-file tokens via hmac.compare_digest — no timing side-channel.
  • Hash-based lookup for API tokens — the database stores only SHA-256 hashes, eliminating timing attacks on token comparison.
  • Local JWT validation — no network call or database query needed per request on server nodes.
  • One-time display of raw API tokens at creation. The plaintext is never stored; token_hash never appears in API responses or logs.
  • Structured logging audit trailctx_user_id is set on every authenticated request and injected into all log events.
  • Scope enforcement at the middleware layer before any handler executes. Path-to-scope mapping is defined statically.

There aren’t any published security advisories