Skip to content

Session Policy & Logout ​

Where a logged-in user's session lives, how "Sign out" ends it, how long a session lasts, and how the Manager stores the credentials it does hold (personal access tokens and machine-user keys).

A browser session lives in two places the Manager never sees: the proxy (caddy-security's authentication portal) and Pocket ID (the identity provider). The Manager holds no passwords and no browser sessions.

Design principle

Pocket ID is the identity source of truth: no passwords, no avatar uploads, session management delegated. Productify adds orchestration, a uniform logout URL and a documented lifetime, never a parallel identity provider.

The logout contract ​

Every Productify app's "Sign out" button works the same way, because the proxy injects one logout URL into the page:

js
// window.__PRODUCTIFY__ (injected by the proxy's productify_html_inject middleware)
{
  // ...user, tenant, application...
  "logout_url": "/_tenant/logout"
}

Your SPA reads window.__PRODUCTIFY__.logout_url and navigates the browser to it on sign-out. Never hardcode a portal path: the proxy owns it, so the contract stays stable across apps and deployments.

One click clears two sessions ​

A logged-in user holds two cookies that must both go:

  1. the caddy-security portal session (the proxy-issued OIDC session cookie), and
  2. the proxy-owned tenant-selection cookie pfy_tenant_<app_id>, a signed cookie recording which tenant the user is acting as.

The portal logout knows nothing about the tenant cookie, so a single portal-logout call would leave a stale tenant selection behind. The contract routes logout through the proxy's tenant selector:

  • logout_url defaults to /_tenant/logout. That route clears the tenant cookie (a deletion cookie, MaxAge: -1) and 303-redirects to the portal logout, /auth/logout by default. One click, both sessions gone.

  • Single-tenant apps with no tenant selector should point the injected URL straight at the portal logout:

    caddyfile
    productify_html_inject {vars.app_id} {
        logout_url /auth/logout
    }
  • If the portal logout is mounted somewhere other than /auth/logout (for example /oauth2/logout), change the selector's redirect target:

    caddyfile
    productify_tenant_selector {vars.app_id} {
        logout_redirect /oauth2/logout
    }

No authentication is required to clear your own tenant cookie; the redirect drives the portal's own session teardown. The portal logout itself is caddy-security's; Productify does not reimplement it.

PropertyValue
Namepfy_tenant_<app_id>: one cookie per application, so selections for different apps never collide
Contenttenant id, tenant name, the user id it was issued to, and an expiry
SignatureHMAC-SHA256 over the content. The key is derived (HMAC-SHA256 PRF) from the proxy's Manager token, the value in the productify { token {env.PFY_MANAGER_TOKEN} } block, so the wire secret itself is never used as a signing key
Lifetime30 minutes, re-issued on use once the cookie is past half its life
FlagsHttpOnly; Secure when the request arrived over TLS; SameSite=Lax
Validation on readsignature must verify, expiry must be in the future, the user id must match the authenticated caller (the portal's X-Token-Subject claim for a browser session), and the membership is re-checked against the Manager (GET /api/proxy/user/:user_id/tenants)

A stolen or replayed cookie is therefore useless to a different user, and a cookie for a tenant the user has since been removed from stops working at the next membership re-check.

Session lifetime & idle policy ​

The portal session lifetime is set in the proxy's caddy-security config (Caddyfile, global security block):

caddyfile
authentication portal pocketportal {
    crypto default token lifetime 3600
    # ...
}
  • token lifetime 3600: the portal session token is valid for 3600 seconds (1 hour) after issue. This is the value in the shipped proxy/Caddyfile and the example Caddyfiles.
  • Absolute, not idle. There is no separate idle timeout: after one hour the user is re-challenged through Pocket ID regardless of activity. Activity does not extend the session.
  • Per-environment tuning. Set this value per deployment: shorter for higher-sensitivity environments, longer where re-authentication friction outweighs the risk. It is the single knob for the proxy-portal session.
  • Pocket ID is independent. Pocket ID enforces its own session policy; token lifetime bounds only the proxy-portal session. A user may still hold a valid Pocket ID session after the portal token expires, in which case re-authentication is a silent redirect rather than a fresh login.
  • Logout wins over lifetime. The logout contract ends the portal session ahead of the lifetime.

See the Configuration Reference for where this sits among the other proxy settings.

Pocket ID session management ​

Session listing and revocation are delegated to Pocket ID, not rebuilt in Productify.

The Manager's Pocket ID client is read-only and user-scoped: it issues a single GET /api/users?search=… with the X-API-Key header for user lookup, and it creates or updates a local user row for an already-authenticated principal without calling Pocket ID. There is no session-listing or session-revocation call to Pocket ID.

  • Session revocation on this platform = logout (the contract above) plus the portal token lifetime bound. Both are proxy-side and immediate or bounded.
  • Cross-device "sign out everywhere" and per-session listing are owned by Pocket ID. Users manage those in Pocket ID's own account UI. Productify does not mirror or proxy that surface.

Credentials the Manager stores ​

The Manager holds no browser sessions, but it does hold three kinds of credential. This is how each is protected.

Personal access tokens (PATs) ​

  • Format: pat_<8-char prefix>_<43-char random>: pat, an 8-character random prefix, and 32 random bytes base64url-encoded.
  • Storage: only a salted PBKDF2-SHA256 hash (600,000 iterations, 16-byte salt) plus the prefix. The plaintext is shown once at creation and never stored.
  • Lookup: the prefix finds the row; the hash verifies the token.
  • Expiry: optional expiresAt, enforced at every request. A scheduled sweep later marks lapsed tokens revoked and writes an expire audit row.
  • Audit: create, revoke and the CLI device-flow mint are recorded.
  • Where PATs are accepted: /query, /pilets/*, /api/audit/*, /api/access/*, and only for requests that also carry the proxy shared secret (see below).

Machine-user credentials ​

  • Bearer tokens: 32 random bytes base64url-encoded, with the first 12 characters kept as a lookup prefix. Stored as a salted PBKDF2-SHA256 hash (600,000 iterations). createMachineUser with hashedKey: "__GENERATE_TOKEN__" generates one and returns the plaintext once.
  • Basic-auth passwords: a password you supply on createMachineUser is hashed the same way before it is stored.
  • hashedKey is not queryable through GraphQL.
  • Rotation: rotateMachineUserToken mints a fresh bearer credential in the request transaction, returns the plaintext once and writes a rotate audit row. It is the only way the stored credential changes: updateMachineUser refuses hashedKey, tokenPrefix and clearTokenPrefix. A scheduled sweep reports credentials older than credential_rotation_max_age (default 2160h, 90 days) on the pfy_executor_stale_credentials gauge and in one other audit row per lapse; it rotates nothing, because there is no channel that could carry a new secret to the holder.
  • Brute-force protection: /api/machine/* and /esb/* share one per-IP and per-prefix throttle (20 failures in 5 minutes; lockouts escalate from 15 s to 10 min) that runs before the expensive hash check. The proxy's credential-validation callback has its own throttle (lockouts from 30 s to 15 min).

Backend trigger-callback tokens ​

The token a registered backend receives for trigger callbacks is the one credential the Manager must be able to replay, so it cannot be hashed. It is stored encrypted at rest with AES-256-GCM (as are the ESB endpoint credentials Endpoint.auth_password / auth_token), keyed by encryption_key / PFY_ENCRYPTION_KEY. It is re-minted on every backend registration, which is the only moment the Manager can hand the backend the new value; the max-age sweep reports it and leaves it alone. The Go SDK re-registers when its heartbeat receives a 404, which is how a backend picks up a re-minted callback token.

The proxy shared secret ​

PFY_PROXY_SECRET on the Manager must equal the token the proxy sends as X-Proxy-Auth. The Manager checks it (constant-time) before reading any identity header or verifying any PAT, so the Manager's port cannot be used as a credential oracle even when it is reachable. With no secret configured the Manager fails closed; the only exception is dev_user_override in a development environment.