Writing masking rules that fail closed

How to write Stubsmith masking rules that default to protecting data: field-family patterns, value-shape matching, and the fail-closed posture.

Most masking implementations fail open. You list the fields you want masked; everything else passes through unchanged. This is the intuitive design: you know your data model, you mark what’s sensitive, and you move on. The problem is that the default behavior is incorrect. Any field you didn’t list, any field added by a third-party API without warning, any field your schema doesn’t document: all of them pass through unmasked until someone notices.

The alternative is a fail-closed posture: broad rules that cover field families rather than individual names, value-shape matching that catches sensitive patterns wherever they appear, and a conservative default for anything that doesn’t fit a clearly safe category. This guide covers what that looks like in Stubsmith’s masking configuration and why the distinction matters.

Fail-open vs. fail-closed

In a fail-open model, you maintain a blocklist of fields to mask. Unknown fields pass through. A new contactEmail field added by an upstream API during a quarterly release, and not on your blocklist, enters your fixtures unmasked. A billing_token that appears in a webhook payload you hadn’t seen before passes through. The model only protects what you’ve already thought of.

In a fail-closed model, the default is protective. You write rules that cover entire families of likely-sensitive fields. You use value-shape patterns to catch sensitive content by what it looks like rather than where it lives. You treat free-text fields as sensitive unless there is a specific reason not to. When an unknown field appears, the question is “is this safe to capture unmasked?” rather than “is this on the blocklist?”

Both models produce identical results for fields you’ve explicitly listed. They diverge on the fields you haven’t thought of, which is exactly where the meaningful failures happen.

Why blocklists accumulate gaps over time

An upstream API ships a new response field: referral_email. Your field_masks list includes email. It does not include referral_email, because you wrote the rules when that field didn’t exist. The new field goes into your fixtures unmasked.

A support integration starts including customer_notes in its webhook payload. You have notes in your list. You don’t have customer_notes. Real customer text, including names, addresses, and complaint details, accumulates in your fixture store.

A payment provider begins returning a recipient_iban field alongside the existing iban field you already mask. You miss it for three weeks until someone reviews a fixture manually.

These failures are the ordinary consequence of a blocklist that grows by exception. The blocklist is always exactly as complete as your attention at the time you last reviewed it.

Field family patterns

Rather than listing each field name individually, think in families and enumerate the plausible variants of each family. Stubsmith’s field_masks configuration takes an array of exact field name strings rather than regex patterns, so you need multiple entries to cover a family, but you can be systematic about it.

Common families and their variants:

  • Email: email, e_mail, emailAddress, email_address, contactEmail, userEmail, billingEmail, replyTo, reply_to
  • Phone: phone, phoneNumber, phone_number, mobile, cell, tel, fax
  • Name: name, firstName, first_name, lastName, last_name, fullName, full_name, displayName, display_name
  • Authentication material: password, passwd, token, access_token, refresh_token, id_token, api_key, apiKey, secret, credential, auth, authorization
  • Identity and government numbers: ssn, sin, dob, date_of_birth, passport, passport_number, license, tax_id, national_id
  • Payment data: credit_card, card_number, cardNumber, cvv, cvc, iban, account_number, accountNumber, routing_number, bic, swift
  • Free-text fields: notes, description, comment, memo, message, reason, instructions, remarks

Adding all plausible variants costs nothing at runtime. Missing a variant costs you a gap in your masking coverage.

{
  "field_masks": [
    "email", "e_mail", "emailAddress", "email_address",
    "contactEmail", "userEmail", "billingEmail", "replyTo", "reply_to",
    "phone", "phoneNumber", "phone_number", "mobile", "tel",
    "name", "firstName", "first_name", "lastName", "last_name",
    "password", "passwd", "token", "access_token", "refresh_token",
    "api_key", "apiKey", "secret", "credential", "auth", "authorization",
    "ssn", "dob", "date_of_birth", "passport",
    "credit_card", "card_number", "cvv", "iban", "account_number",
    "notes", "description", "comment", "memo", "message", "reason"
  ],
  "regex_masks": [
    {
      "pattern": "\\b[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}\\b",
      "replace": "user@stub.invalid"
    },
    {
      "pattern": "\\b[A-Z]{2}\\d{2}[A-Z0-9]{4}\\d{7}([A-Z0-9]?){0,16}\\b",
      "replace": "XX00XXXX0000000"
    },
    {
      "pattern": "\\b\\d{16}\\b",
      "replace": "0000000000000000"
    },
    {
      "pattern": "Bearer\\s+[A-Za-z0-9\\-\\._~\\+\\/=]{8,}",
      "replace": "Bearer <masked>"
    },
    {
      "pattern": "\\b\\d{3}-\\d{2}-\\d{4}\\b",
      "replace": "000-00-0000"
    }
  ]
}

Note the replacement domain. The SDK is told your project’s placeholder domain and generates fake addresses on it, which keeps your stubs consistent. The server does not check or reject anything based on it, so this is a convention rather than an enforced rule. When no placeholder domain is set, the fallback is stub.invalid, so that is what the examples use. Replace emails with an address on your placeholder domain so every stub looks the same.

The regex_masks patterns are Python re syntax: they’re applied by the Python SDK and match against string values wherever they appear, regardless of which field they’re in. The server validates them on save with a JavaScript RegExp check but never executes them, so stick to syntax both engines share (no variable-width lookbehind, no possessive quantifiers, no named-group differences). This is the second layer of the fail-closed approach: catch sensitive content by what it looks like, not only by where it is named.

Value-shape matching as the second layer

Some sensitive values have recognizable shapes wherever they appear. Email addresses, IBANs, card numbers, US SSNs, and bearer tokens all have structure you can express as a pattern. Adding regex_masks for these shapes means a misnamed field, an unexpected field from a third party, or an email address mentioned in free text is caught even when the field name doesn’t trigger a match.

Consider a payload where an email address appears inside a free-text notes field. A field name rule won’t reach it, but the value-shape rule does:

Before masking (what the middleware receives at the edge):

{
  "order_id": "ORD-8821",
  "status": "pending",
  "notes": "Customer john.smith@example.com called to update the shipping address.",
  "amount": 149.00,
  "is_flagged": false
}

After masking (what leaves your process, with no keep rules configured):

{
  "order_id": "<masked>",
  "status": "<masked>",
  "notes": "<masked>",
  "amount": 0,
  "is_flagged": false
}

Strings become <masked> unless you have given that exact field a keep rule, so order_id and status are masked too. The notes field was in field_masks in the configuration above, so the entire value is blanked. If you had given notes a keep rule, the email regex pattern in regex_masks would still catch the email address within the string and replace it with user@stub.invalid. The amount became 0 because default masking converts all numbers to 0. The is_flagged boolean becomes false. The structural shape of the payload is intact; a test that checks the type or presence of these fields continues to work. The value that would identify a person is gone.

The default for free-text fields

Fields typed as arbitrary strings (notes, description, comment, memo, reason, message) are where sensitive content most often appears in unstructured form. Regex patterns catch some of it (emails, phone numbers in common formats, IBANs), but natural language contains PII that resists pattern matching: “Please deliver to Jane at 14 Elm Street” is sensitive, and no regex catches it reliably.

The fail-closed approach to free-text is to add these field names to field_masks explicitly. The full string value is replaced by a placeholder. The field remains present and correctly typed, still a string, but its content is gone.

Teams sometimes resist this because they have tests that assert on the content of a notes field. That assertion is usually a mistake in the test, not a reason to leave the field unmasked. If your test logic depends on specific text in a free-text field, that dependency should be expressed in the test itself with a fixture you control, not through a captured production value.

Testing rules before enabling capture

Before pointing any real traffic at the ingest service, verify your rules against representative sample payloads. Use dummy data, never real user data, to compose a realistic-looking request/response pair for each distinct API shape you capture. Run those samples through the Stubsmith dashboard’s rules preview. Check that every field that should be masked shows its placeholder and that fields your tests rely on remain structurally present.

Do this for each distinct response shape your service handles. A payment webhook payload has a different field set than a user profile response; both need their own coverage review. Gaps are cheap to find and fix at this stage. They are expensive to find after real traffic has been captured.

How fail-closed fingerprints fit

The SDK computes the structural fingerprint on the raw body inside your process, before masking runs, and sends only the digest alongside the masked body. The digest covers key-paths, query-parameter names and content type. By default no values enter the digest; a variant key you opt in adds that value’s hash. The fail-closed behavior continues on the server in one way: a fingerprint Stubsmith has not seen before is held as pending, and no bodies are stored, only the request shape and a count, until a human approves the shape. Ingest also runs an image backstop that replaces any body declared as an image, unless it is already the canonical 1x1 placeholder, with that placeholder. Ingest does not scan other values for personal information, so the masking that happens in your process is what protects them.

The consequence is that a new traffic shape does not appear in your fixtures until you approve it. That is the right trade-off. A missing fixture is a gap you can fill deliberately by adjusting your rules and re-running. A fixture containing unmasked personal data is a compliance incident you discover much later, from a worse position.

A checklist before enabling capture in production

  • Every field name in your API’s response schemas is covered by a field_masks entry or explicitly reviewed and confirmed non-sensitive.
  • Common variants of each sensitive field name are included, not just the canonical form (email, emailAddress, contactEmail, etc.).
  • regex_masks covers email addresses, payment card numbers, IBANs, bearer tokens, and any other value-shaped sensitive patterns your APIs return.
  • All free-text fields (notes, description, comment, message, reason) are in field_masks.
  • You have run representative sample payloads through the rules preview and confirmed the masked output looks correct.
  • You have reviewed what remains after masking as a set, not field by field, to check whether the combination of unmasked fields creates re-identification risk.

Rules are cheap to add and free to update. The cost of a gap in your rules is not.

Integration tests from real API behavior

Capture once, replay in CI, with values masked before they leave your process.