Skip to content

Web UI

dotvault includes an optional web dashboard for browser-based authentication, status monitoring, secret inspection, enrolment, and managed SSH forwards. Every page is rendered on the server and has a real, bookmarkable URL.

Three surfaces sit behind one entry point at /, which decides where you land:

You are / shows you
Not signed in the login view for the configured auth method
Signed in with nothing enrolled yet the first-run enrolment wizard at /setup/
Signed in the main site at /ui/

Enabling the web UI

web:
  enabled: true
  listen: "127.0.0.1:9000"

Loopback only

The listen address must be a loopback address (127.0.0.1, [::1], or localhost). dotvault will refuse to start if a non-loopback address is configured. This is a hard security constraint that cannot be overridden.

Login view

The login view lives at / and adapts to vault.auth_method. It deliberately uses no JavaScript at all: its waiting states advance with a meta refresh, so signing in works with scripting disabled.

The web UI supports every auth method:

  • OIDC — "Login with OIDC" button redirects to the identity provider (via /auth/oidc/start, returning to /auth/oidc/callback — the redirect URI registered in Vault and your IdP is unchanged)
  • LDAP — username/password form with inline MFA handling. After submitting, a progress page waits for a Duo push approval (refreshing itself until Vault answers) or prompts for a TOTP passcode. The passcode prompt does not auto-refresh, so it cannot wipe what you are typing
  • Token — paste a Vault token to authenticate
  • mTLS (mtls, mtls+tpm, mtls+os) — certificate auth needs no credential in normal operation, so there is no recurring login; the page simply reports that the daemon is signing in with its client certificate. The one and only interactive moment is the one-time certificate bootstrap on a new host, which presents whichever of the LDAP or OIDC forms above vault.mtls.bootstrap_method names, framed as an enrolment. Because the daemon still has to sign and install the certificate after that login returns, the page then reports that issuance is in flight and refreshes until the daemon holds a token. This is what lets a host with no terminal bootstrap — notably dotvaultw.exe, the GUI-subsystem Windows binary with no console.

Note that the token login (POST /login/token) is refused with 403 under certificate auth: there the operational token comes from the certificate login alone, so pasting one would install a credential the certificate flow never sanctioned.

First-run enrolment wizard

On a host where nothing has been enrolled yet, signing in lands on /setup/: a single page listing every configured enrolment as a card you can Start or Skip, plus a control at the bottom that takes you out to the main site.

The wizard appears only while no enrolment has been completed. As soon as you have one credential from an enrolment that needed you — or you leave the wizard yourself — / takes you straight to the main site, and any outstanding enrolment waits for you on /ui/enrolments/ instead of interrupting every visit. Skipping an enrolment counts as having dealt with it, so once every enrolment is either done or skipped the wizard stands aside on its own. The control at the bottom of the page always takes you out. With nothing left to do it is a plain link onward to the dashboard; with enrolments still outstanding it offers to skip the rest, which is a real decision rather than a navigation and so is a button.

Enrolments that need no interaction — the copy engine, which mirrors an existing Vault secret — do not appear here at all. One of them being outstanding will not raise the wizard, since there would be nothing for you to do in it; one of them completing does not count as your having been through setup, since it completes on its own without you; and neither does it take up a card on a page whose whole purpose is the things you have to do. You will find it, with its description and controls, under Enrolments on the main site.

Enrolments that need input (the SSH engine's passphrase, for example) prompt inside their card; a card waiting on you stops refreshing so it cannot wipe what you are typing.

Main site

Status dashboard

Shows at a glance:

  • Authentication state and Vault token TTL
  • Vault server address and KV mount configuration
  • Per-rule sync status (last synced, secret version)
  • Username and user prefix

Secret inspection

Browse and inspect secrets synced by dotvault. Secrets are hidden by default and require explicit reveal (?reveal=true). Nested Vault paths — such as a grouped enrolment written under databricks/prod — render in the sidebar as expandable folders that lazy-load their contents on first open, mirroring the grouped layout on the enrolment screen.

Editing secrets

The secret browser is read-only until an administrator names one or more editable subtrees in web.editable_paths. With that set, editing happens on the secret's own page — there is no separate editor view. A pencil beside a field turns that row into inputs, next to the eye and clipboard buttons already there; save or cancel puts it back. Opening a row, and cancelling it, leave the rest of the page exactly as it was. Saving reloads the page, though, so finish one row before opening another — a save discards whatever was typed into any other open row.

Opening a row reveals that field's value — there is no editing a value you cannot see — so the pencil is a deliberate gesture like the eye beside it, one row at a time, and the daemon logs it the same way (the path, never the field name or the value). Every other cell on the page stays masked.

Editing needs JavaScript. The pencil, Add field and New secret are script-driven, like the eye and clipboard buttons already on those rows — with scripting off they still appear but do nothing when clicked, and what works is the read-only view plus Delete. If you need to write without a browser, the JSON API and the filesystem mount both do it losslessly.

The editable subtrees are listed in the Secrets sidebar whether or not they exist yet. A subtree holds nothing until the first secret is written into it, and a folder you cannot see is one you cannot create in. (If your Vault policy grants write on those paths without granting list on the folder above them, that is fine: a listing failure inside a configured subtree is treated as "empty" rather than reported. Everywhere else it is still an error.)

Creating a secret

New secret on a folder asks for a name relative to that folder and takes you to it — personal plus aws lands on /ui/secrets/personal/aws. Naming a secret does not create it: that URL simply holds nothing yet, which is what makes the page there a create form. Add the first field and the secret comes into being. The URL is the whole state, so there is nothing to keep in step between "new" and "existing".

The name is a single segment, because your key space is one folder deep — the same shape an enrolment key has, flat or grouped exactly once. A nested name like aws/dev is refused rather than taking you to a page that could never save.

Versions, and not clobbering each other

Every save carries the version the row was rendered from, and Vault refuses the write if the secret has moved on since. So if something else changes a secret while you have it open — the daemon's own enrolment refresh, another browser tab, the filesystem mount, the API — your save is refused with an explanation rather than silently overwriting whatever landed in between. The page comes back showing what is in Vault now — which means the text you had typed is gone, so copy anything you cannot retype out of the box before saving a row you have had open for a while. Creating works the same way: the first field is written on the condition that nothing exists at that path, so two simultaneous creates cannot both appear to succeed.

Within one row you can change the value, rename the field (the old name goes, the value moves), or remove the field. Removing the last field is refused, since Vault does not store a fieldless secret and deleting the whole secret is its own gesture. Adding a field — or renaming one onto a name the secret already has — is refused rather than quietly replacing what is there: the field that would lose is the one you were not looking at.

What survives a round trip, and what cannot

  • A value you do not edit is written back byte for byte, including its type: a field holding the number 1000000 stays a number, and one holding CRLF line endings keeps them. Editing a structured value turns it into a string; editing a multi-line one stores it with \n endings, because every browser submits a text area as CRLF whatever the value held and the server cannot tell which it was.
  • Multi-line values are safe. A PEM private key or certificate keeps its line breaks, including a value that begins with one — the value box is a resizable text area rather than a single-line input.
  • Names cannot contain line breaks or leading/trailing whitespace. A single-line input discards those, so saving would rename the field rather than edit it. Rather than corrupt it, the page withholds the pencil on such a secret and says why, naming the JSON API and the filesystem mount — both of which edit it losslessly, the mount when fuse.read_write is on. Delete still works, since it renders no name back.

Secrets an enrolment owns stay read-only even inside an editable subtree, and the page says so rather than silently dropping the controls: dotvault rewrites those at the engine's next run, so an edit there would be lost without warning. That protection comes from the configuration rather than from what Vault currently holds, so a configured enrolment that has not run yet is protected too — you cannot create a secret at the path an enrolment will later claim, only to have it overwritten the first time the engine runs. Nothing outside the configured subtrees is editable, including the root of your key space.

Delete removes every version. It is the same operation as rm on the filesystem mount — a KVv2 metadata delete, with no undelete. The form asks you to type the secret's name back before it will run.

Manual sync

Trigger an immediate sync cycle from the dashboard without waiting for the next poll interval.

Copy Vault token

A clipboard icon in the header bar allows you to copy the current active Vault token to the clipboard. This lets you authenticate directly to the Vault web UI using your existing token, avoiding a repeated multi-factor authentication flow.

Customisable content

You can display markdown text on the login page and secret view page:

web:
  enabled: true
  listen: "127.0.0.1:9000"
  login_text: |
    Welcome to **dotvault**. Click Login to authenticate via your
    organisation's single sign-on.
  secret_view_text: |
    These secrets are synchronised from Vault. Contact IT support
    if you need additional credentials provisioned.

Pages

The main site lives under /ui/ (e.g. http://127.0.0.1:9000/ui/). Navigation is a left-hand accordion — Enrolments, Remotes, Secrets — where each section expands when its route is active:

  • /ui/ — the index page: the header (version, connection state, Vault link on the left; live "Updated" time, Config, copy-token, and Sync Now on the right) plus the configured secret_view_text markdown in the content column. The "Updated" time is a live value streamed over Server-Sent Events.
  • /ui/secrets/ — Secrets panel expanded; /ui/secrets/<key> shows a secret (heading linked to the secret in the Vault UI, revision subheading, and a field/value/actions table). Values are masked until the eye icon reveals them — the reveal auto-hides after 30 seconds — and the clipboard icon copies the value server-side: the secret never travels to the browser at all.
  • /ui/enrolments/ — Enrolments panel expanded, grouped by engine (an engine nests into a folder only when it has more than one enrolment) with a status dot per entry: green = enrolled, orange = in progress, red = error, grey = not started. /ui/enrolments/<engine>/<key>/ is where the enrolment itself runs.
  • /ui/remotes/ — managed SSH forwards with the same status-dot vocabulary (green = connected, orange = connecting, red = enabled but disconnected, grey = disabled) and an add form revealed by the Add button; /ui/remotes/<host>/ edits one remote (port, remote socket, enabled switch, delete) and shows its pinned host key — the SHA256 fingerprint with the full key revealable on demand, or a note when trust comes from a configured certificate authority instead. Adding an unpinned host presents the same fingerprint-confirmation gesture the CLI uses. Both pages subscribe to a live state stream (/ui/sse/ssh), so state and configuration changes — a forward connecting or dropping, a remote edited, added, or removed, including asynchronously by dotvault ssh or the daemon's own reconnect loop — appear without refreshing the page.
  • /ui/config/ — the Effective Configuration view, with the left navigation kept in place.

Interactivity comes from datastar patching server-rendered fragments over SSE; there is no client-side application state. An unauthenticated visit to any /ui/ page redirects to /, which owns all login flows.

Security

  • CSRF protection — every browser POST (the login forms, the wizard, and the /ui/ actions) requires a present, same-origin Origin header: browsers attach one to every POST, so a cross-site request — or one with no Origin at all — is rejected. This suits server-rendered forms and multi-tab use better than a one-shot token, and it protects the login forms too: without it a hostile page could post its own Vault token and have the daemon adopt an attacker-chosen identity. The JSON API endpoints used by dotvault ssh and scripts still use the token handshake (GET /api/v1/csrf), with three deliberate exceptions: the peer-action endpoints POST /api/v1/remote/browse, POST /api/v1/remote/notify, and POST /api/v1/remote/clipboard (see below)
  • Content Security Policy — default-src 'self'; frame-ancestors 'none' prevents XSS via injected scripts and clickjacking via framing. The scripted pages (/ui/ and /setup/) additionally allow 'unsafe-eval' for scripts (datastar compiles its data-* attribute expressions with the Function constructor) and inline styles; the login view keeps the strict policy, and inline scripts are forbidden everywhere
  • X-Content-Type-Options — nosniff header on all responses
  • Loopback binding — enforced at startup; non-loopback addresses are rejected

API endpoints

The web UI communicates with the dotvault daemon via a REST API:

Method Path Description
GET /api/v1/status Server status, auth state, token TTL, sync state, certificate-bootstrap state
GET /api/v1/rules Configured sync rules
GET /api/v1/token Current Vault token (authenticated sessions only)
GET /api/v1/secrets/{path} List or reveal a secret
POST /api/v1/secrets/{path} Create a secret; 409 if one already exists (CSRF-protected)
PUT /api/v1/secrets/{path} Replace a secret; 404 if absent (CSRF-protected)
DELETE /api/v1/secrets/{path} Delete a secret and every version of it (CSRF-protected)
POST /api/v1/sync Trigger immediate sync (CSRF-protected)
POST /api/v1/remote/browse Open a form-posted url in this host's default browser (not CSRF-protected)
POST /api/v1/remote/notify Raise a form-posted desktop notification on this host (not CSRF-protected)
POST /api/v1/remote/clipboard Put form-posted text on this host's clipboard (not CSRF-protected)
GET /api/v1/csrf Obtain a one-time CSRF token

The three secret mutations write inside web.editable_paths and answer 403 everywhere else, so they are registered whether or not anything is configured — what grants the capability is the policy, not the route table. They are the non-browser way to do what the secret editor does, and the answer when a field name carries a line break or surrounding whitespace the form cannot round-trip. The body is {"fields": {...}}, the same object GET …?reveal=true returns, so a caller can read, edit and send it straight back; unlike the browser's per-row save it replaces the whole document, exactly as a write through the filesystem mount does. They are ordinary CSRF-protected mutations, so each needs a token from GET /api/v1/csrf echoed back in X-CSRF-Token:

TOKEN=$(curl -s http://127.0.0.1:9000/api/v1/csrf | jq -r .token)
curl -X PUT http://127.0.0.1:9000/api/v1/secrets/personal/token \
  -H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"fields": {"value": "new"}}'

POST /api/v1/remote/browse is the outbound counterpart of GET /api/v1/token: over the same SSH-forwarded Unix socket that lets a headless peer borrow the workstation's token, it lets the peer hand a URL back so browser-driven flows open where a browser actually exists — see dotvault browse. It accepts a form POST (url=https://..., body only — the query string is ignored) and only http/https URLs with a host and no embedded user:pass@ credentials; file:// and custom protocol schemes are rejected before anything reaches the OS URL opener, and only one browser open runs at a time (concurrent requests get a 503). It is deliberately exempt from the CSRF handshake: its consumer is a bare curl/dotvault browse POST with no practical way to run the issue-then-spend token dance, and it reads no state and returns nothing sensitive. Cross-site browser traffic is rejected by an Origin check instead — browsers always attach an Origin header to cross-origin POSTs, and only the daemon's own origin (a loopback hostname on the daemon's own listener port — a page served by any other loopback server does not qualify) is accepted; curl and the CLI send no Origin and pass.

POST /api/v1/remote/notify is the same idea for desktop notifications — see dotvault notify. It accepts a form POST with level (one of info, warning, error, attention), title, an optional body, and an optional action_url, and raises a native notification (Windows toast / macOS Notification Center / Linux D-Bus). action_url (http/https, the same allowlist as browse) makes the notification open that URL when clicked on Windows, and is appended to the body on macOS/Linux where a one-shot notification cannot be made clickable. It shares the browse endpoint's security posture exactly: no CSRF, the same Origin check, body-only fields, and a single-flight bounded delivery. Its input-validation control restricts level to the known set, validates action_url against the http/https allowlist, and sanitizes title/body — stripping control characters and neutralizing the metacharacters that would otherwise break out of the Windows toast backends (an XML CDATA section and a PowerShell here-string), so a crafted notification cannot inject toast XML or execute a PowerShell subexpression on the delivering host. Log lines record the level and field lengths only, never the title/body text or the action_url (arbitrary, potentially capability-bearing content that may name secret systems).

POST /api/v1/remote/clipboard is the third peer action — see dotvault clipboard. It accepts a form POST with a single text field (body only — a URL is the last place a secret should travel) and places the value on this host's clipboard, so a headless peer can stage a one-time token or device code right where the user's paste is after remote/browse opened the page that asks for it. It shares the browse/notify security posture exactly: no CSRF, the same Origin check, and a single-flight bounded write. Validation rejects input no clipboard can carry faithfully (interior NULs, invalid UTF-8) or that signals a caller bug (empty, over 64 KiB); the text is otherwise written verbatim — clipboard content is data, never interpolated into an evaluated context, and the typical payload is a credential that must arrive byte-for-byte intact. This endpoint carries the most secret-bearing payload of the three, so log lines record the text's length only — never the content — and writer errors are additionally scrubbed of the exact text before logging or being returned (best-effort defense in depth; the writers never embed their input in errors).

Browser endpoints (present only when web.enabled; all POSTs are Origin-checked):

Method Path Description
GET / Login view, or a redirect onward to /setup/ or /ui/
GET /auth/oidc/start Redirect to Vault OIDC auth URL
GET /auth/oidc/callback Handle OIDC callback
POST /login/ldap Start an async LDAP login
GET /login/ldap LDAP progress page (MFA wait or passcode prompt)
POST /login/ldap/totp Submit an MFA passcode
POST /login/token Validate and adopt a pasted token (403 under certificate auth)
GET /setup/ First-run enrolment wizard
POST /setup/complete Abandon whatever is still outstanding and enter the main site

Removed endpoints

The browser surface above replaces a single-page application that drove login and enrolment through JSON endpoints. Those endpoints existed only to serve that application and have been removed along with it — the equivalent work is now done by the form POSTs and page GETs listed above.

Removed Replacement
POST /auth/ldap/login POST /login/ldap (form POST, redirects to the progress page)
GET /auth/ldap/status GET /login/ldap (renders the state rather than returning it)
POST /auth/ldap/mfa POST /login/ldap/totp
POST /auth/token/login POST /login/token
GET /api/v1/enrol and the /api/v1/enrol/* action endpoints /setup/ and /ui/enrolments/ with their form POSTs

Nothing under /api/v1/ other than the enrolment endpoints changed: the status, rules, config, secrets, sync, token, SSH-remote, and peer-action routes are unaffected, so anything scripting against those keeps working. If you were driving login or enrolment programmatically, use dotvault login and dotvault enrol instead — those are the supported non-browser paths and always were.