Back to Journal
privacysecuritydatabasewebdev

I Can Finally Screen-Share My SQL Client Without Leaking Prod Data

How data-peek auto-masks PII columns with regex rules, a CSS blur, and an Alt-to-peek escape hatch — so you can demo, record, or pair on production data without the pre-flight panic.

Rohith Gilla
Engineer
7 min read read

We were halfway through a customer demo when I remembered I was connected to staging, not to the demo seed database. I had just typed SELECT * FROM users LIMIT 20 and hit Cmd+Enter. Twenty real email addresses appeared on my screen, which was mirrored to a conference room of people who were not supposed to see them.

I alt-tabbed to my Zoom window so fast I think I pulled a tendon.

There was no harm done — staging data is obfuscated, the emails were fake, the customer is still a customer. But the adrenaline was real, and the sheer avoidable stupidity of the situation stuck with me. Every SQL client I have ever used will happily render hunter2 in plaintext to whoever is pointing a camera at your laptop. That is not a sensible default in 2026.

So I added a data masking layer to data-peek.

A users table query with email, password_hash, and token columns auto-blurred — the shape of the data is visible but the contents are not
A users table query with email, password_hash, and token columns auto-blurred — the shape of the data is visible but the contents are not

#What it does

Two things, really.

It auto-masks columns by name. Out of the box, every column whose name matches email, password, passwd, pwd, ssn, social_security, token, secret, api_key, or apikey is blurred. Phone and address patterns are in the rule list but disabled by default because they create too many false positives on arbitrary schemas. The rules are just regexes, so you can add your own (for my team: a stripe_ rule that catches stripe_customer_id and friends).

You can manually mask any column, any time. Click a column header, hit "Mask Column," done. The masked state is scoped per tab, so masking users.full_name in one query does not affect a different query.

Masked cells render with filter: blur(5px) and user-select: none. You can see the shape of the data — same row height, same column width, no layout shift — but not the contents. When you actually need to see a value, hold Alt and hover. The cell reveals for as long as you hold it and re-blurs when you let go.

That hover-to-peek mode is the best part. It keeps you in the flow: "checking a single email address to verify an account" no longer means revealing twenty of them.

Alt-hover revealing a single email cell while the rest of the column stays blurred
Alt-hover revealing a single email cell while the rest of the column stays blurred

#The rules

The masking toolbar with auto-mask rules listed, each with an enable toggle and editable regex pattern
The masking toolbar with auto-mask rules listed, each with an enable toggle and editable regex pattern

These are the defaults, straight from src/renderer/src/stores/masking-store.ts:

~/ts
ts
const DEFAULT_RULES: AutoMaskRule[] = [
  { id: 'email',    pattern: 'email',                                 enabled: true  },
  { id: 'password', pattern: 'password|passwd|pwd',                   enabled: true  },
  { id: 'ssn',      pattern: 'ssn|social_security',                   enabled: true  },
  { id: 'token',    pattern: 'token|secret|api_key|apikey',           enabled: true  },
  { id: 'phone',    pattern: 'phone|mobile|cell',                     enabled: false },
  { id: 'address',  pattern: 'address|street',                        enabled: false }
]

Two things I learned writing the matcher.

First, case insensitivity is mandatory, not optional. Different ORMs and naming conventions will give you email, Email, EMAIL, emailAddress, email_addr. The matcher compiles each pattern as new RegExp(rule.pattern, 'i') so one rule catches them all. Without that flag, Email slips through every time and you have a false sense of security.

Second, the effective mask is the union of manual and auto. If you manually unmask a column but the auto-rule still matches, the auto-rule wins on the next render. This was a deliberate choice: the whole point is to fail closed. If you want to permanently exclude a column, you edit the rule, not the cell.

Here is the resolver that combines both sources:

~/ts
ts
getEffectiveMaskedColumns: (tabId, allColumns) => {
  const { maskedColumns, autoMaskRules, autoMaskEnabled } = get()
  const manualMasked = new Set(maskedColumns[tabId] ?? [])
 
  if (!autoMaskEnabled) return manualMasked
 
  const effective = new Set(manualMasked)
  for (const col of allColumns) {
    for (const rule of autoMaskRules) {
      if (!rule.enabled) continue
      try {
        const regex = new RegExp(rule.pattern, 'i')
        if (regex.test(col)) {
          effective.add(col)
          break
        }
      } catch {
        // Invalid regex — skip
      }
    }
  }
  return effective
}

The try/catch around the regex is there because the rule list is user-editable. If someone adds user[ as a pattern, I do not want the entire results grid to crash. The invalid rule silently no-ops. A production-grade version would surface a red squiggle in the rule editor; I did not do that yet.

#The render path

The masking logic lives in Zustand; the render logic lives in one small cell component. The meaningful lines from data-table.tsx:

~/tsx
tsx
function MaskedCell({ isMasked, hoverToPeek, children }: MaskedCellProps) {
  const [peeking, setPeeking] = useState(false)
 
  const onMouseEnter = (e: React.MouseEvent) => {
    if (hoverToPeek && e.altKey) setPeeking(true)
  }
 
  return (
    <span
      onMouseEnter={onMouseEnter}
      onMouseLeave={() => setPeeking(false)}
      style={peeking ? undefined : { filter: 'blur(5px)', userSelect: 'none' }}
    >
      {children}
    </span>
  )
}

That is the whole thing. No canvas trickery, no data-URL sleight of hand, no modified result set — the raw value is still in the DOM. If someone is smart enough to open devtools on your SQL client during a demo, they can dig it out. The threat model here is "accidentally revealing data to a camera or screen-share," not "malicious insider with inspector access." I think that is the right level to aim at. Perfect is the enemy of I will actually use it every day.

The userSelect: 'none' matters more than the blur: it means you cannot double-click and copy a masked value into the clipboard by reflex. One of the quiet ways PII leaks is not from someone reading your screen, it is from you pasting a blurred value into Slack thinking "surely the blur meant that wasn't the real thing."

#What I'd do differently

I wish I had done it the other way around. The blur is a presentation trick. A truly paranoid version would mask at the IPC boundary — have the main process redact values before they ever hit the renderer, based on the same rules. That way a devtools inspector genuinely cannot see the original. The tradeoff is that hover-to-peek becomes a round-trip through IPC, which adds latency to the interaction. I chose the fast UX and a weaker threat model. I still think it is the right call, but I want the IPC-redaction option as a toggle for security-conscious users.

The rules should be repo-shareable. Right now each user's rules live in their own Zustand-persisted local storage. But a team would reasonably want a shared rule set — "our customers table has a pci_encrypted_pan column, mask that everywhere" — and right now there is no way to distribute that short of everyone copy-pasting it manually. A .data-peek-masks.json at the repo root would solve it. Queued up.

The phone pattern should probably be on by default. It is off because mobile_application_id and friends match the pattern and create noise. But "noise from too much masking" is a strictly better failure mode than "leaking a phone number in a Loom." I will flip the default.

#The honest pitch

If you have ever had a "I was sharing my screen" moment, this feature is for you. If you record Loom demos, pair on production bugs, or stream your coding, this feature is definitely for you.

data-peek is at datapeek.dev. The masking code is open source — src/renderer/src/stores/masking-store.ts if you want to read it or port the idea to your own tool. Copy it, improve it, tell me what you did differently. The goal is fewer adrenaline spikes in conference rooms.

Join the Future.

A database client built for professional developers. Experience the speed of native code and the power of AI.