# Password-protected routes
URL: https://openship.io/docs/guides/route-password-protection.md

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

```ts
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);
```

```bash
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.

```ts
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.
