diff --git a/e2e/tests/variables.spec.ts b/e2e/tests/variables.spec.ts
index 69d9f200..14cc284e 100644
--- a/e2e/tests/variables.spec.ts
+++ b/e2e/tests/variables.spec.ts
@@ -63,6 +63,27 @@ test.describe('Variables', () => {
await expect(row.locator('text=***')).toBeVisible();
});
+ test('sensitive variable value is masked on entry, revealable', async ({ page }) => {
+ await page.goto(`/workspaces/${workspaceId}?tab=variables`);
+
+ await page.click('button:has-text("Add Variable")');
+ const valField = page.locator('#var-val');
+ await valField.fill('shoulder-surf-me');
+
+ // Not sensitive yet -> value shown normally (no masking class).
+ await expect(valField).not.toHaveClass(/text-masked/);
+
+ // Mark sensitive -> the entry field masks (cross-browser disc font).
+ await page.locator('label:has-text("Sensitive") input[type="checkbox"]').check();
+ await expect(valField).toHaveClass(/text-masked/);
+
+ // Show toggle reveals it; hiding masks again.
+ await page.click('button[aria-label="Show value"]');
+ await expect(valField).not.toHaveClass(/text-masked/);
+ await page.click('button[aria-label="Hide value"]');
+ await expect(valField).toHaveClass(/text-masked/);
+ });
+
test('delete variable removes it from list', async ({ page }) => {
const varKey = `DELETE_e2e_${Date.now()}`;
diff --git a/web/public/fonts/text-security-disc.woff2 b/web/public/fonts/text-security-disc.woff2
new file mode 100644
index 00000000..658e00dd
Binary files /dev/null and b/web/public/fonts/text-security-disc.woff2 differ
diff --git a/web/src/app/admin/variable-sets/[id]/page.tsx b/web/src/app/admin/variable-sets/[id]/page.tsx
index fce26f2b..c11343ab 100644
--- a/web/src/app/admin/variable-sets/[id]/page.tsx
+++ b/web/src/app/admin/variable-sets/[id]/page.tsx
@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/page-header'
import { LoadingSpinner } from '@/components/loading-spinner'
import { ErrorBanner } from '@/components/error-banner'
import { EmptyState } from '@/components/empty-state'
+import { SensitiveValueInput } from '@/components/sensitive-value-input'
import { getAuthState, isAdmin } from '@/lib/auth'
import { apiFetch } from '@/lib/api'
import { usePollingInterval } from '@/lib/use-polling-interval'
@@ -514,7 +515,7 @@ export default function VariableSetDetailPage() {
@@ -569,7 +570,8 @@ export default function VariableSetDetailPage() {
className="w-full px-2 py-1 text-sm border border-slate-600 rounded bg-slate-700 text-slate-100 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500" />
-
- setEditVarValue(e.target.value)}
+
diff --git a/web/src/components/sensitive-value-input.tsx b/web/src/components/sensitive-value-input.tsx
new file mode 100644
index 00000000..e4994801
--- /dev/null
+++ b/web/src/components/sensitive-value-input.tsx
@@ -0,0 +1,75 @@
+'use client'
+
+import { useState } from 'react'
+
+interface SensitiveValueInputProps {
+ id?: string
+ value: string
+ onChange: (value: string) => void
+ sensitive: boolean
+ placeholder?: string
+ rows?: number
+ className: string
+}
+
+/**
+ * Variable-value entry field. When the variable is marked sensitive the typed
+ * text is masked by default (with a Show/Hide toggle), so a secret isn't
+ * visible on screen — or shoulder-surfable — while it's being entered. It stays
+ * a multi-line textarea so certs / keys paste intact; masking swaps in the
+ * `text-security-disc` font (the `.text-masked` class in globals.css) so it
+ * works in every browser, Firefox included — `-webkit-text-security` is
+ * Chromium/WebKit-only. Non-sensitive values render exactly as before.
+ */
+export function SensitiveValueInput({
+ id,
+ value,
+ onChange,
+ sensitive,
+ placeholder,
+ rows = 2,
+ className,
+}: SensitiveValueInputProps) {
+ const [reveal, setReveal] = useState(false)
+
+ if (!sensitive) {
+ return (
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ rows={rows}
+ className={className}
+ />
+ )
+ }
+
+ return (
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ rows={rows}
+ // Mask the characters when hidden via the cross-browser disc font;
+ // revealing (or an empty field) drops the class so the value shows
+ // normally and the placeholder stays readable (the mask is a font,
+ // so it would otherwise dot-out the placeholder too).
+ className={reveal || value.length === 0 ? className : `${className} text-masked`}
+ autoComplete="off"
+ spellCheck={false}
+ />
+ setReveal((r) => !r)}
+ tabIndex={-1}
+ aria-label={reveal ? 'Hide value' : 'Show value'}
+ className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-slate-200"
+ >
+ {reveal ? 'Hide' : 'Show'}
+
+
+ )
+}