Auth & Access Control
Every way Octopus authenticates and authorizes — JWT (API), cookie sessions (admin), cookie-JWT (client portal), WebAuthn passkeys, TOTP 2FA, IP whitelisting, RBAC, and multi-tenant scoping — plus the security gaps to fix.
Octopus has four independent auth surfaces, each with its own mechanism. They all share one JWT signing secret and one repository-level tenant guard. This page is the map; it also flags a cluster of real security gaps the next maintainer should triage early (see Security findings).
Read this first
Several controls that comments and docs imply exist are not wired up: CSRF is defined but commented out, there is no rate limiting anywhere (including OTP/login), the "automation blocker" middleware does not exist in code, and TOTP secrets are stored in plaintext. Details in Security findings.
The four surfaces at a glance
| Route group | Mechanism | Guard middleware (in order) | Wired at |
|---|---|---|---|
/auth/* | Public login/refresh; JWT+IP for logout/cleanup | JWTAuthMiddleware, IPWhitelistMiddleware (logout/cleanup only) | http/routes/routes.go:50 |
/api/v1/* | Bearer access JWT + IP whitelist | jwtAuthMiddleware, ipWhitelistMiddleware (group-level) | routes.go:77 |
/g2a/* | OAuth2 client-credentials bearer (Subject g2a) | G2AAuthMiddleware() (token endpoint public) | routes.go:62 |
/admin/* | Cookie session (Valkey-backed) | AdminAuthMiddleware, then AdminWriteMiddleware / AdminSuperUserMiddleware | routes/admin.go:31 |
/client/* | httpOnly-cookie JWT | ClientCookieAuthMiddleware on /client/api; /client/auth/* public | routes/client.go:18 |
/webhooks/* | Vendor-authenticated (signature / path token) — no JWT | none | routes.go:109 |
Global stack (main.go:490): panic recovery → otelfiber → tracing → metrics → CORS (web.IsAllowedOrigin, AllowCredentials:true) → helmet → CSRF (commented out, main.go:513).
The JWT layer
Core: utils/jwt.go. Algorithm is HS256 (symmetric) — one secret signs everything.
- Signing secret comes from env
JWT_SECRET, with an insecure hardcoded fallback"your-super-secret-key". It is read independently in four middlewares (middleware/auth.go:23,112,client_cookie_auth.go:26,g2a_auth.go:34). NoAPP_KEYis involved (that key encrypts Shopify/voucher data elsewhere). - Claims (
JWTClaims):client_id+ standard registered claims (jtiuuid, exp, iat, Subject). The user id is not in the JWT — it lives only on theauth_tokensDB row. - Token types by Subject — cross-surface replay is blocked by strict Subject checks:
| Subject | Lifetime | Use |
|---|---|---|
access | 1h | /api/v1 + client portal access cookie |
refresh | 7d | single-use rotating refresh |
g2a | 15m | G2A inbound channel |
pending_2fa | 5m | client 2FA gate between password and TOTP |
- Defense-in-depth validation (API path,
middleware/auth.go:49): JWT sig/exp → DB lookup inauth_tokens→IsRevokedcheck → DB expiry →UserID != nil⇒ rejected (portal tokens barred from the machine API) → client-active check. The client-cookie path is the mirror image (UserID == nil⇒ rejected). - Signing-method pin:
ValidateTokenrejects non-HMAC algorithms — guards againstalg=none/ HS-RS confusion. - Refresh rotation (
auth.go:96,client_auth.go:152): single-use. Race safety viaRevokeAuthTokenIfActive— an atomic revoke-iff-active claim so concurrent/replayed refreshes mint at most one new pair. - Logout: API
/auth/logoutrevokes all tenant tokens; client/client/auth/logoutrevokes just the current pair and clears cookies.
Every issued token is also persisted to auth_tokens (models.AuthToken) carrying IsRevoked, ExpiresAt, nullable UserID. Revocation and the API-vs-portal split both key off this row — the JWT alone is never trusted.
Client API auth — JWT plus IP whitelist
/api/v1 requires both a valid access JWT and a source IP on the tenant's whitelist. IP check runs after JWT so client_id is known.
- Enforcement (
middleware/ip_whitelist.go:22): loads activeip_whitelist_entries, allow-all if the list is empty, matches the client IP against each CIDR. Client IP is taken fromX-Real-IPwith the trusted-proxy set limited to127.0.0.1/::1(main.go:484) so clients can't spoof it. - The whitelist is also enforced at login/refresh (
Handler.checkIPWhitelist,auth.go:270) — a near-verbatim clone of the middleware. Keep the two copies in sync. - API credentials (
client_settings.go:113): per-key username + 32-char random password, bcrypt-hashed at rest, plaintext returned once./auth/loginexchanges username+password (bcrypt verify) for a JWT pair.
The IP whitelist fails open on DB error (ip_whitelist.go:47, auth.go:277) — availability over security, intentional per comments. A whitelist with zero entries allows all IPs. Don't assume "whitelist configured" == "restricted" unless entries exist.
WebAuthn / passkeys (client portal only)
Service services/webauthn.go, handlers http/handler/passkey.go, model passkeys + webauthn_challenges.
- RP config:
WEBAUTHN_RP_ID(defaultlocalhost),WEBAUTHN_RP_NAME,WEBAUTHN_ORIGINS(comma-split). - Registration:
BeginRegistrationexcludes existing creds, requires resident key + user verification, stores a challenge row (5-min TTL).FinishRegistrationverifies, persists thePasskey(credential_id, public_key, aaguid, sign_count, transports). - Login (discoverable / usernameless):
BeginDiscoverableLogin(UV required) →FinishLoginresolves by RawID, active-check, updates the sign count, then issues the normal cookie JWT pair. - Management routes under
/client/api/passkeys/*; public begin/finish under/client/auth/passkey/*.
Passkey login bypasses the TOTP 2FA gate that password login enforces (by design — the passkey itself is the second factor). Clone-detection data is captured but CloneWarning is hardcoded false.
TOTP / 2FA
Shared service services/totp.go; admin admin_2fa.go, client client_2fa.go.
- Params: 30s period, 256-bit secret, 6 digits, SHA1 (authenticator compat), issuer
APP_NAME(+" Admin"for admin). - Backup codes: 10 codes,
XXXX-XXXX, bcrypt-hashed at rest, one-time use. - Admin enrollment: setup-begin stores a pending secret → setup-complete validates a code, enables 2FA, generates backup codes. Disable requires password re-auth. Super-admins can reset another user's 2FA.
- Login gates:
- Admin: password OK + 2FA enabled →
CreatePending2FASession(Valkey, 5-min, cookieadmin_2fa_pending) →/admin/login/2faverify → real session. - Client: password OK →
pending_2faJWT (5-min) cookie scoped to/client/auth→ClientVerify2FAHandler→ real tokens.
- Admin: password OK + 2FA enabled →
Two gaps here
TOTP shared secrets are stored in plaintext (repo/client_user.go:485, repo/admin_user.go:428) — backup codes are hashed, the seed is not. And there is no rate limit on TOTP / backup-code verification, so codes are brute-forceable within each 30s window.
Admin sessions
Service services/admin_session.go.
- Cookie
admin_session: 256-bit random id,Path=/admin,HTTPOnly,SameSite=Strict,Secure=falsehardcoded (comment says "set true in prod" but it is not env-gated — unlike client cookies). - Storage: primary store is Valkey (
admin:session:prefix, 24h TTL); a DBadmin_sessionsrow is also written for audit (non-fatal on failure). - Lifetime: 24h sliding —
RefreshSessionextends on each authenticated request;ShouldRefreshafter 1h idle. - Session payload carries role,
IsSuperAdmin,TwoFactorEnabled, and a session-onlyViewOnlyModeflag. - Pending-2FA sessions use a parallel
admin:2fa_pending:prefix, 5-min TTL.
Client portal auth & password reset
Handlers client_auth.go, client_auth_password_reset.go.
- Password hashing: bcrypt. Cost mismatch — client/API use DefaultCost 10 (
jwt.go:150), admin uses 12 (admin_auth.go:196). - Cookies:
octopus_access(Path=/client) +octopus_refresh(Path=/client/auth), both httpOnly.Secure/SameSite/Domainare env-gated — production ⇒Secure=true,SameSite=Lax,Domain=COOKIE_DOMAIN. - Password reset (
client_auth_password_reset.go): anti-enumeration (always generic response); token is 256-bit random, SHA-256 hash stored (raw emailed), 30-min TTL, single live token per user; the token is consumed before the password write (guarded byused_at IS NULL) to prevent replay.
Authorization (RBAC)
Admin (models/admin_user.go): roles viewer / admin / super_admin + boolean IsSuperAdmin.
SessionData.CanWrite():ViewOnlyMode→ false; super-admin → true; else role must beadmin. Enforced byAdminWriteMiddlewareon every write route.AdminSuperUserMiddlewaregates user-management routes.AdminRoleMiddleware(...)exists but appears unused (dead code).- View-only mode is a self-service session toggle, not a persisted permission.
Client portal (models/client_user.go): roles owner / admin / write / read, surfaced as capabilities (CanCreateOrders, CanManagePayouts, CanManageUsers, CanAccessBilling).
- Enforced by
RequireClientPermission(perm)on order/cart/payout-creating routes. Unknown permission ⇒ deny. - ⚠️
ClientGetMeHandlerreturns all-true permissions when no user record resolves (client_auth.go:376) — display-only (real enforcement uses the locals user), but misleading.
Multi-tenant scoping
The primary tenant-isolation mechanism is at the repository layer, not the handler:
- Every auth middleware sets
c.Locals("client_id")and injectstypes.TenantIDKeyinto the requestcontext. - Repos read it via
IsClientContext(ctx)(repo/repo.go:253) and appendWHERE client_id = ?(e.g.repo/transaction.go:29,76,123, cart, cart_item). - Admin context has no tenant id → admin queries are intentionally unscoped.
Scoping is opt-in per query. Any repo method that forgets the IsClientContext guard leaks across tenants. This is worth a dedicated audit — every client-reachable repo method must apply the guard.
Security findings
Surfaced during the code read. Ranked; all are in-repo (nginx/infra layer not audited here).
| Severity | Finding | Where |
|---|---|---|
| High | CSRF disabled — CSRFConfig() exists but is commented out (main.go:513). Templates render csrf tokens that are never validated. Admin panel relies solely on SameSite=Strict. | main.go:513, middleware/csrf_middleware.go |
| High | TOTP secrets stored plaintext at rest (backup codes are hashed; seeds are not). | repo/client_user.go:485, repo/admin_user.go:428 |
| High | No rate limiting anywhere — none on OTP/2FA, client login, forgot-password, or refresh. No limiter middleware exists in code. | codebase-wide |
| High | JWT secret falls back to hardcoded "your-super-secret-key" when JWT_SECRET is unset — no startup assertion. | utils/jwt.go + 4 middleware sites |
| Medium | "Automation blocker" middleware does not exist — comments reference it (routes.go:60) but no such middleware is in the codebase. Either removed with stale comments, or never built. | routes.go:60 + comments |
| Medium | Admin session cookie Secure=false hardcoded (not env-gated). | services/admin_session.go:55 |
| Medium | No client-side login lockout — admin has a 5-strike / 15-min lock (admin_auth.go:33); client login has no equivalent. | client_auth.go:18 |
| Low | bcrypt cost mismatch: admin 12 vs client/API 10. | admin_auth.go:196 vs jwt.go:150 |
| Low | Dead/misleading CookieAccessExpiry=15m vs actual 1h access token. | config/cookie.go:17 |
| Low | checkIPWhitelist logic duplicated in handler and middleware. | auth.go:270 / ip_whitelist.go:22 |
| Low | AdminRoleMiddleware appears unused. | admin_middleware.go:188 |
Cross-references
The automation-blocker rule in CLAUDE.md describes a production control (X-Automation-Request blocking) — the research above found no matching middleware in this codebase. Verify whether it lives at the nginx layer or has been dropped before relying on it. See also Secrets & Config for where JWT_SECRET, APP_KEY, and cookie config are sourced.
Key files
- Middleware:
middleware/{auth,client_cookie_auth,ip_whitelist,g2a_auth,csrf_middleware}.go; admin session/role middleware inhttp/handler/admin_middleware.go - JWT/crypto:
utils/jwt.go; cookie configconfig/cookie.go - Services:
services/{webauthn,totp,admin_auth,admin_session}.go - Handlers:
http/handler/{auth,admin_auth,client_auth,client_auth_password_reset,admin_2fa,client_2fa,passkey,client_ip_whitelist,client_settings,admin_users}.go - Models:
database/models/{auth_token,admin_user,client_user,passkey,ip_whitelist}.go
Error & Failure Codes
The three error systems — the HTTP AppError catalog (29 singletons), the Err* control-flow sentinels grouped by subsystem, and the client-facing FailureCode enum with vendor mappings — plus the duplicate frameworks, verbatim leaks, and dead gRPC block.
Payouts
The payout product line — beneficiaries, providers, scheduled payouts, the money-out ledger, and the two fulfilment providers (Merit, Ledig). Includes the critical "pipeline wired but not driven" finding.