---
title: "Auto-Redaction"
description: "Automatically scrub PII from wide events before console output and drains. Built-in smart masking for credit cards, emails, IPs, phone numbers, JWTs, and more."
canonical_url: "https://www.evlog.dev/learn/redaction"
---
# Auto-Redaction

> Automatically scrub PII from wide events before console output and drains. Built-in smart masking for credit cards, emails, IPs, phone numbers, JWTs, and more.

Wide events capture comprehensive context, which makes it easy to accidentally log sensitive data. Auto-redaction scrubs PII from events **before** console output and **before** any drain sees the data.

**Redaction is enabled by default in production** (`NODE_ENV === 'production'`). In development, it is off so you see full values for debugging. No configuration needed, just deploy.

## Turn it off, or narrow it

If you need to disable redaction in production:

<framework-tabs>

```typescript [Nuxt]nuxt.config.ts
export default defineNuxtConfig({
  modules: ['evlog/nuxt'],
  evlog: {
    redact: false,
  },
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'

export const { withEvlog, useLogger } = createEvlog({
  service: 'my-app',
  redact: false,
})
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'

initLogger({
  env: { service: 'my-app' },
  redact: false,
})
```

</framework-tabs>

You can also enable redaction explicitly in development with `redact: true`.

<redaction-stream>



</redaction-stream>

## Mask enough to still debug

Built-in patterns use **partial masking** instead of flat `[REDACTED]`, preserving enough context for debugging while protecting the actual data.

<table>
<thead>
  <tr>
    <th>
      Pattern
    </th>
    
    <th>
      Example Input
    </th>
    
    <th>
      Masked Output
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        creditCard
      </code>
    </td>
    
    <td>
      <code>
        4111111111111111
      </code>
    </td>
    
    <td>
      <code>
        ****1111
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        email
      </code>
    </td>
    
    <td>
      <code>
        alice@example.com
      </code>
    </td>
    
    <td>
      <code>
        a***@***.com
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ipv4
      </code>
    </td>
    
    <td>
      <code>
        192.168.1.100
      </code>
    </td>
    
    <td>
      <code>
        ***.***.***.100
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        phone
      </code>
    </td>
    
    <td>
      <code>
        +33 6 12 34 56 78
      </code>
    </td>
    
    <td>
      <code>
        +33 ****5678
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        jwt
      </code>
    </td>
    
    <td>
      <code>
        eyJhbGciOiJIUzI1NiIs...
      </code>
    </td>
    
    <td>
      <code>
        eyJ***.***
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bearer
      </code>
    </td>
    
    <td>
      <code>
        Bearer sk_live_abc123...
      </code>
    </td>
    
    <td>
      <code>
        Bearer ***
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        iban
      </code>
    </td>
    
    <td>
      <code>
        FR76 3000 6000 0112 ...189
      </code>
    </td>
    
    <td>
      <code>
        FR76****189
      </code>
    </td>
  </tr>
</tbody>
</table>

<callout color="info" icon="i-lucide-info">

`127.0.0.1` and `0.0.0.0` are excluded from IPv4 masking since they are not real client addresses.

</callout>

## Secrets by key name

A password or an API token has no recognizable shape, so the `secrets` built-in looks at the **key** instead of the value. Any key ending in `password`, `passwd`, `secret` or `token` (case-insensitive, at any depth) has its value replaced with `replacement`, `[REDACTED]` by default:

<table>
<thead>
  <tr>
    <th>
      Key
    </th>
    
    <th>
      Redacted
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        password
      </code>
      
      , <code>
        newPassword
      </code>
      
      , <code>
        user_password
      </code>
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        secret
      </code>
      
      , <code>
        clientSecret
      </code>
      
      , <code>
        client_secret
      </code>
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token
      </code>
      
      , <code>
        accessToken
      </code>
      
      , <code>
        refresh_token
      </code>
      
      , <code>
        API_TOKEN
      </code>
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        totalTokens
      </code>
      
      , <code>
        tokensPerSecond
      </code>
      
      , <code>
        tokenType
      </code>
    </td>
    
    <td>
      No
    </td>
  </tr>
</tbody>
</table>

Secrets are replaced in full, never partially masked: unlike a card number, no part of a password is safe or useful to keep. Booleans and `null` are left alone, so a flag like `hasPassword: true` stays readable.

It is still a safety net. Choose the fields you pass to `log.set()` rather than relying on it.

## Credentials by value

`secrets` only reads the key, so a token logged under `apiKey`, inside an error message or in a connection string passes through it. Three opt-in built-ins match the value instead, under any key and inside longer strings, and keep the part that is safe to read:

<table>
<thead>
  <tr>
    <th>
      Pattern
    </th>
    
    <th>
      Example Input
    </th>
    
    <th>
      Masked Output
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        apiKeys
      </code>
    </td>
    
    <td>
      <code>
        ghp_
      </code>
      
       followed by 36 characters
    </td>
    
    <td>
      <code>
        ghp_***
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        privateKey
      </code>
    </td>
    
    <td>
      <code>
        -----BEGIN RSA PRIVATE KEY-----
      </code>
      
       block
    </td>
    
    <td>
      <code>
        -----BEGIN RSA PRIVATE KEY-----***
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        urlPassword
      </code>
    </td>
    
    <td>
      <code>
        postgres://app:hunter2@db:5432/app
      </code>
    </td>
    
    <td>
      <code>
        postgres://app:***@db:5432/app
      </code>
    </td>
  </tr>
</tbody>
</table>

`apiKeys` matches GitHub (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), npm (`npm_`), Anthropic (`sk-ant-`), OpenAI (`sk-proj-`), Stripe (`sk_live_`, `rk_live_`), Slack (`xoxb-`, `xoxp-`, `xapp-`) and AWS access key IDs (`AKIA`, `ASIA`). `privateKey` masks a block with no END line to the end of the value. `urlPassword` also masks a URL with no user name, `redis://:***@cache:6379`.

They are off by default, so upgrading evlog does not change what your logs already show. `builtins` runs only the names it lists, so list them with the defaults:

```typescript
evlog: {
  redact: {
    builtins: [
      'creditCard', 'email', 'ipv4', 'phone', 'jwt', 'bearer', 'iban', 'secrets',
      'apiKeys', 'privateKey', 'urlPassword',
    ],
  }
}
```

## Configuration

### Path Patterns

Use a single `paths` array with dot-notation and globs. A bare segment like `password` is shorthand for `**.password`, so it redacts that key at **any nesting depth**:

```typescript
evlog: {
  redact: {
    paths: [
      'password',              // same as '**.password'
      '*_token',               // key-name glob at any depth
      'headers.x-forwarded-for', // exact path
      'user.*',                // everything directly under user
    ],
  }
}
```

<table>
<thead>
  <tr>
    <th>
      Pattern
    </th>
    
    <th>
      Matches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        user.email
      </code>
    </td>
    
    <td>
      Exact path only
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        password
      </code>
      
       or <code>
        **.password
      </code>
    </td>
    
    <td>
      <code>
        password
      </code>
      
       key at any depth
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        *_token
      </code>
    </td>
    
    <td>
      Key names like <code>
        access_token
      </code>
      
      , <code>
        refresh_token
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        user.*
      </code>
    </td>
    
    <td>
      <code>
        user.email
      </code>
      
      , <code>
        user.password
      </code>
      
      , etc.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        audit.changes.*.password
      </code>
    </td>
    
    <td>
      Mixed exact + wildcard segments
    </td>
  </tr>
</tbody>
</table>

Path redaction replaces the **entire value** (including nested objects) with `replacement`. Use `patterns` when you need regex on **string values** inside fields.

This matches `auditDiff({ redactPaths: ['password'] })`, the same glob syntax, applied globally at emit time.

### Selective Built-ins

Pick only the patterns you need:

```typescript
evlog: {
  redact: {
    builtins: ['email', 'creditCard'],
  }
}
```

### Custom Patterns

Add your own regex patterns. These use the flat `replacement` string, not smart masking:

```typescript
evlog: {
  redact: {
    patterns: [/SECRET_\w+/g, /sk_live_\w+/g],
    replacement: '***',
  }
}
```

### Computed Replacements

When the replacement has to be **derived** from the value it replaces, pass a function instead of a string. It runs at the same point as the rest of redaction: before the console write, before any drain.

The common case is keeping requests correlatable without exposing the credential that identifies them:

```typescript
initLogger({
  redact: {
    patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g],
    replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(ctx.groups[0])}]`,
  },
})
// /public/claim/eyJhbGciOi...  →  /public/claim/[tok:9f3a1c]
```

The function receives the matched value and a context object:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        path
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      Dot-notation path from the event root (<code>
        user.email
      </code>
      
      , <code>
        items.0.token
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        key
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      Leaf key of the field (<code>
        email
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        groups
      </code>
    </td>
    
    <td>
      <code>
        string[]
      </code>
    </td>
    
    <td>
      Capture groups of the matching <code>
        patterns
      </code>
      
       entry. Only set for <code>
        patterns
      </code>
    </td>
  </tr>
</tbody>
</table>

For `paths`, the matched value is the **whole field value**, of any type, since path redaction replaces entire subtrees. For `patterns`, it is the matched substring.

If the function throws or returns a non-string, redaction falls back to `[REDACTED]` and logs the failure. A broken policy degrades to over-redaction, never to leaking the value it was meant to scrub.

### Conditional Policies

Some policies cannot be expressed as a list of paths: redact a field only for certain tenants, only when a sibling field has a given value, or keep an allowlist rather than a denylist. Use `transform`:

```typescript
initLogger({
  redact: {
    transform: (event) => {
      if (event.tenant === 'regulated') delete event.query
    },
  },
})
```

`transform` runs **before** `paths`, `builtins`, and `patterns`, so it sees raw values and the declarative rules still apply to whatever it leaves behind, and a hook that misses a field is not your last line of defence. Mutate the event in place; it is already a private clone, so the object you logged is never touched.

It must be synchronous, since it runs on the emit path before the console write. Errors are caught and reported like drain failures: the declarative stages still run and the event is still logged.

<callout color="warning" icon="i-lucide-triangle-alert">

Function-valued `replacement` and `transform` cannot be declared in `nuxt.config.ts` or a Nitro module's options, that config is serialized to JSON at build time, which drops functions. Declare them at runtime with `initLogger()` from a server plugin, or with `createEvlog()`. The modules emit a build-time warning if you do it anyway.

</callout>

### Disable Built-ins

If you only want custom redaction:

```typescript
evlog: {
  redact: {
    builtins: false,
    paths: ['user.ssn'],
    patterns: [/INTERNAL_\w+/g],
  }
}
```

## Configuration Reference

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        redact
      </code>
    </td>
    
    <td>
      <code>
        boolean | RedactConfig
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
      
       in production
    </td>
    
    <td>
      Enabled by default in production. <code>
        false
      </code>
      
       to disable. Object for fine-grained control
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        paths
      </code>
    </td>
    
    <td>
      <code>
        string[]
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Dot-notation paths with globs (<code>
        password
      </code>
      
      , <code>
        **.password
      </code>
      
      , <code>
        *_token
      </code>
      
      , <code>
        user.*
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        patterns
      </code>
    </td>
    
    <td>
      <code>
        RegExp[]
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Custom regex on string values. Uses flat <code>
        replacement
      </code>
      
       string
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        builtins
      </code>
    </td>
    
    <td>
      <code>
        false | string[]
      </code>
    </td>
    
    <td>
      All except <code>
        apiKeys
      </code>
      
      , <code>
        privateKey
      </code>
      
      , <code>
        urlPassword
      </code>
    </td>
    
    <td>
      <code>
        false
      </code>
      
       disables built-ins. Array selects specific ones
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        replacement
      </code>
    </td>
    
    <td>
      <code>
        string | (matched, ctx) => string
      </code>
    </td>
    
    <td>
      <code>
        '[REDACTED]'
      </code>
    </td>
    
    <td>
      Replacement for paths and custom patterns. Built-ins use smart masking instead. A function computes it from the matched value
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transform
      </code>
    </td>
    
    <td>
      <code>
        (event) => void
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Escape hatch for policies that are conditional, tenant-scoped, or allowlist-shaped. Runs before the declarative stages
    </td>
  </tr>
</tbody>
</table>

Available built-in names: `creditCard`, `email`, `ipv4`, `phone`, `jwt`, `bearer`, `iban`, `secrets`, and the opt-in [`apiKeys`, `privateKey`, `urlPassword`](#credentials-by-value).

## Where redaction runs in the pipeline

Redaction runs inside the emit pipeline, after the wide event is fully built but before any output:

1. **Transform**: your `transform` hook, if any, sees the raw event first
2. **Path redaction**: exact paths and globs replaced with `[REDACTED]`
3. **Smart masking**: built-in patterns scan all string values recursively with partial masking
4. **Pattern redaction**: custom regex patterns scan all string values with flat replacement
5. **Enrich**: enrichers add their fields to the masked event
6. **Console output**: the event printed to stdout
7. **Drain**: the same event sent to external services

Redaction runs once, before enrichers. Fields an enricher adds afterwards are not scrubbed, so an enricher that reads a header or a session must mask what it writes. Stdout and drains receive the same event, so anything that needs to happen before output belongs in `transform` or a function-valued `replacement`.

<callout color="info" icon="i-lucide-zap">

Redaction runs synchronously on the event loop when the event is emitted. Hono emits before it returns the response, so the caller waits for it, except for a streaming response, which Hono returns first and emits when the stream ends. Express emits once the response has finished. The cost of the built-in patterns grows linearly with the length of the values you log, whatever characters they contain.

</callout>

## A configuration that ships

Redaction is already on by default in production. Combine with sampling for a typical setup:

<framework-tabs>

```typescript [Nuxt]nuxt.config.ts
export default defineNuxtConfig({
  modules: ['evlog/nuxt'],
  evlog: {
    env: { service: 'my-app' },
  },
  $production: {
    evlog: {
      sampling: {
        rates: { info: 10, debug: 0 },
        keep: [{ status: 400 }, { duration: 1000 }],
      },
    },
  },
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'

export const { withEvlog, useLogger } = createEvlog({
  service: 'my-app',
  sampling: {
    rates: { info: 10, debug: 0 },
    keep: [{ status: 400 }, { duration: 1000 }],
  },
})
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'

initLogger({
  env: { service: 'my-app' },
  sampling: {
    rates: { info: 10, debug: 0 },
    keep: [{ status: 400 }, { duration: 1000 }],
  },
})
```

</framework-tabs>

## See what a redacted event looks like

Without redaction, sensitive data lands in your logs and drains:

```json
{
  "user": { "email": "alice@example.com", "ip": "192.168.1.42" },
  "payment": { "card": "4111111111111111" },
  "auth": "Bearer sk_live_abc123def456"
}
```

With `redact: true`:

```json
{
  "user": { "email": "a***@***.com", "ip": "***.***.***.42" },
  "payment": { "card": "****1111" },
  "auth": "Bearer ***"
}
```

Same debugging context, no PII in your Axiom/Datadog/Sentry.

## Next Steps

- [Best Practices](https://www.evlog.dev/reference/best-practices) - Security guidelines and production checklist
- [Sampling](https://www.evlog.dev/learn/sampling) - Control log volume in production
- [Configuration](https://www.evlog.dev/reference/configuration) - Full configuration reference

---

- [Best Practices](https://www.evlog.dev/reference/best-practices)
- [Configuration](https://www.evlog.dev/reference/configuration)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
