Guides

Password-protected routes

Restrict a self-hosted site with an edge-served password form and local browser session checks.

Use the lock icon beside a custom hostname in the deployment wizard or Domains & Routes to set a shared password. Visitors see an edge-served sign-in page. A successful login sets a host-only, Secure, HttpOnly, SameSite=Lax cookie for 24 hours.

This first version supports exact custom hostnames on self-hosted Linux OpenResty edges, including the Docker edge used by Desktop. HTTPS must terminate on that edge. Update the edge image before enabling it; older images are rejected without changing the route. Cloud, cluster targets, free subdomains, wildcard hosts, and externally terminated HTTP origins are not supported. They cannot silently accept and ignore a password.

The lock protects one hostname. Protect every serving alias separately. Direct origin ports retain their own access rules; keep those private. If a CDN previously cached a public copy, purge that content and configure it to respect the new private, no-store headers. The edge cannot withdraw files already downloaded by a visitor or stored by an external CDN.

Changes and recovery

  • Save password creates or changes the password and signs out existing browsers.
  • Sign out all browsers rotates session credentials without changing the password.
  • Retry reapplies the saved change.
  • Remove protection asks for confirmation before making the hostname public.

A saved change is pending until the edge accepts its configuration. A failed update does not imply that a previously public site is private. For a new deployment, the wizard saves the policy before starting the build, and route publication reads it from the same repository used by live edits. Deploy, redeploy, and routing retry therefore enforce the saved policy.

The policy survives edge restarts independently of the control plane. Password verifiers and signing keys are encrypted in the database and written to private edge configuration files; read APIs return status only. Transfers use the existing sealed-secret bundle. An export without secrets retains the lock intent and requires a new password before publication. Moving a protected project to an unsupported target is rejected.

Request cost and limits

Public routes have no password-authentication Lua handler. A protected request verifies the cookie's expiry, hostname binding, and HMAC locally. It does not call the API, a database, or Redis, read a file, or run a password derivation. The policy cache is only an optimization: a cache miss reloads the policy from the nginx configuration, never treating it as public.

Initial sign-in uses OpenSSL PBKDF2-HMAC-SHA256 with 600,000 iterations in OpenResty's worker thread pool, outside its request event loop. Expensive checks are bounded to two at a time, with five attempts per client/policy per minute and 30 per edge per minute. These are sign-in limits, not ordinary request limits. Exhausted counters or unavailable authentication code deny access. There is a small CPU cost for authenticated requests; it is not literally zero.

Missing or expired cookies get an HTML prompt for browser navigation and a 401 for other requests. WebSockets require authentication at the handshake; revocation applies to subsequent requests, not an already-established stream. ACME and edge ownership verification tokens remain reachable, but APIs, assets, and webhook paths are protected.

SDK and CLI

const projectId = "proj_your_project";
await ship.projects.changeRouteProtection(projectId, {
  hostname: "preview.example.com",
  action: "protect",
  password: process.env.PREVIEW_PASSWORD!,
});
const status = await ship.projects.listRouteProtection(projectId);
openship project routing protection protect PROJECT_ID preview.example.com \
  --password-file /path/to/private-password
openship project routing protection list PROJECT_ID
openship project routing protection revoke PROJECT_ID preview.example.com
openship project routing protection retry PROJECT_ID preview.example.com
openship project routing protection unlock PROJECT_ID preview.example.com

The password file can be - to read stdin. One final newline is removed; spaces are preserved. Avoid putting passwords in command arguments, checked-in files, or build environment variables. The dedicated operation is shared by the dashboard, HTTP API, SDK, native transport, CLI, and MCP.

Protect pull request previews

In the source project's Git → Pull request previews, set a custom preview base domain and a Password source hostname. First save a password for that exact hostname in Domains & Routes. The hostname must belong to this source project; it may have a pending policy before its first deployment.

const projectId = "project_id";
await ship.projects.configurePreviews(projectId, {
  enabled: true,
  comments: true,
  baseDomain: "preview.example.com",
  protectionHostname: "dev.example.com",
});

Only the hostname reference is stored in preview settings. Native preview admission seals a policy for each generated hostname before route publication; redeploy, retry and new aliases use the same guard. Missing source credentials, unsupported edges and invalid ownership stop publication instead of making the preview public. Each hostname has a distinct signing key; source browser sessions cannot be used to sign in to a preview.

Password changes and Sign out all browsers on the source hostname enqueue existing previews through the durable reconciler. An offline edge keeps its last applied protection until retry succeeds; revocation is not instantaneous across unavailable previews. Manage inherited passwords and sessions on the source, not on individual previews. Disabling previews still schedules normal cleanup.

To change or remove the password-source reference, first disable previews and wait for their environments to be removed. This prevents existing public previews from being mistaken for protected ones during a settings change. Unprotected preview configurations remain explicitly public when this field is empty.

On this page