Redacting secrets and PII in FastAPI logs (Authorization, tokens, fields)

Sensitive data in logs is a security and compliance risk. Authorization headers, API tokens, passwords, and personal data regularly leak into logs. fapilog provides built-in redaction with sensible defaults and extensible patterns.

Safe by Default

fapilog redacts URL credentials automatically—no configuration required:

from fastapi import FastAPI
from fapilog.fastapi import FastAPIBuilder

app = FastAPI(
    lifespan=FastAPIBuilder()
        .with_preset("production")
        .build()
)

With this setup, URLs containing credentials are automatically scrubbed:

# What you log
await logger.info("Connecting to database", url="postgres://admin:secret123@db.example.com/mydb")

# What appears in logs
{"message": "Connecting to database", "url": "postgres://db.example.com/mydb"}

The url_credentials redactor is enabled by default and strips user:pass@ from any URL-like strings.

What Gets Redacted by Default

fapilog ships with five built-in redactors:

Redactor

Enabled by Default

What It Does

url_credentials

Yes

Strips user:pass@ from URLs

field_mask

No (Yes with preset)

Masks specific field names

regex_mask

No (Yes with preset)

Masks fields matching regex patterns

field_blocker

No (Yes with hardened)

Blocks high-risk field names (body, payload, etc.)

string_truncate

No (explicit opt-in)

Truncates long strings and appends [truncated] marker

The default configuration prioritizes safety without being overly aggressive. URL credentials are the most common accidental leak, so they’re handled automatically.

Full Protection with Presets

The production, adaptive, and serverless presets automatically apply the CREDENTIALS redaction preset, which masks:

  • Passwords: password, passwd, pwd

  • API keys: api_key, apikey, api_token

  • Tokens: token, access_token, refresh_token, auth_token

  • Secrets: secret, api_secret, client_secret, private_key

  • Auth headers: authorization, auth_header

Adding Field-Based Redaction

To redact specific fields by name, use with_redaction():

from fapilog import LoggerBuilder

logger = await (
    LoggerBuilder()
    .with_redaction(
        fields=["password", "ssn", "credit_card", "user.api_key"],
        mask="[REDACTED]",
    )
    .build_async()
)

# What you log
await logger.info("User signup", password="hunter2", email="user@example.com")

# What appears in logs
{"message": "User signup", "password": "[REDACTED]", "email": "user@example.com"}

Auto-Prefix Behavior

By default, simple field names (without dots) are automatically prefixed with data. to match the log envelope structure:

# These are equivalent:
.with_redaction(fields=["password"])           # Auto-prefixed to data.password
.with_redaction(fields=["data.password"], auto_prefix=False)

To disable auto-prefixing:

.with_redaction(fields=["password"], auto_prefix=False)

Nested Field Paths

Field paths support dot notation for nested objects:

logger = await (
    LoggerBuilder()
    .with_redaction(fields=["user.password", "config.api_key"], auto_prefix=False)
    .build_async()
)

await logger.info(
    "Config loaded",
    user={"name": "alice", "password": "secret"},
    config={"api_key": "sk-123", "timeout": 30},
)
# user.password and config.api_key are masked; other fields preserved

Adding Pattern-Based Redaction

For dynamic field names or broader matching, use regex patterns:

logger = await (
    LoggerBuilder()
    .with_redaction(
        patterns=[
            r"(?i).*password.*",     # Any field containing "password"
            r"(?i).*secret.*",       # Any field containing "secret"
            r"(?i).*token.*",        # Any field containing "token"
            r"(?i)context\.auth.*",  # Auth fields in context
        ]
    )
    .build_async()
)

Patterns match against the full dot-path of fields (e.g., context.auth_token), not field values.

Using Compliance Presets

For regulation compliance, use built-in redaction presets:

from fapilog import LoggerBuilder

# GDPR compliance
logger = await (
    LoggerBuilder()
    .with_redaction(preset="GDPR_PII")
    .build_async()
)

# HIPAA compliance
logger = await (
    LoggerBuilder()
    .with_redaction(preset="HIPAA_PHI")
    .build_async()
)

# Multiple regulations
logger = await (
    LoggerBuilder()
    .with_redaction(preset=["GDPR_PII", "PCI_DSS"])
    .build_async()
)

Available presets:

  • GDPR_PII - EU GDPR personal data

  • GDPR_PII_UK - UK GDPR (includes NHS numbers, NI numbers)

  • CCPA_PII - California Consumer Privacy Act

  • HIPAA_PHI - HIPAA Protected Health Information

  • PCI_DSS - Payment card data

  • CREDENTIALS - Authentication secrets

Discovering Presets

from fapilog import LoggerBuilder

# List all available presets
presets = LoggerBuilder.list_redaction_presets()
print(presets)  # ['CCPA_PII', 'CREDENTIALS', 'GDPR_PII', ...]

# Get preset details
info = LoggerBuilder.get_redaction_preset_info("GDPR_PII")
print(info["description"])  # "GDPR Article 4 personal data identifiers"
print(info["fields"][:5])   # ['email', 'phone', 'name', ...]

Combining Presets with Custom Fields

Presets and custom fields are additive:

logger = await (
    LoggerBuilder()
    .with_redaction(preset="GDPR_PII")
    .with_redaction(fields=["internal_user_id", "employee_badge"])
    .build_async()
)

Configuration via Settings

You can also configure redaction through Settings:

from fapilog import Settings

settings = Settings()

# Enable specific redactors
settings.core.redactors = ["field_mask", "regex_mask", "url_credentials"]

# Configure field_mask
settings.redactor_config.field_mask.fields_to_mask = [
    "password",
    "authorization",
    "api_key",
]
settings.redactor_config.field_mask.mask_string = "[REDACTED]"

# Configure regex_mask
settings.redactor_config.regex_mask.patterns = [
    r"(?i).*secret.*",
    r"(?i).*token.*",
]

Or via environment variables:

export FAPILOG_CORE__REDACTORS='["field_mask", "url_credentials"]'
export FAPILOG_REDACTOR_CONFIG__FIELD_MASK__FIELDS_TO_MASK='["password", "ssn"]'

Testing Your Redaction Rules

Verify that sensitive data is actually redacted before deploying:

import pytest
from fapilog import LoggerBuilder
from fapilog.testing import capture_logs


@pytest.mark.asyncio
async def test_password_is_redacted():
    """Verify password fields are masked in log output."""
    async with capture_logs() as logs:
        logger = await (
            LoggerBuilder()
            .with_redaction(fields=["password"])
            .build_async()
        )
        await logger.info("Login attempt", username="alice", password="hunter2")

    # Password value should not appear
    assert "hunter2" not in logs.text
    # Mask should appear instead
    assert "***" in logs.text or "[REDACTED]" in logs.text


@pytest.mark.asyncio
async def test_ssn_pattern_redacted():
    """Verify SSN-like fields are caught by regex pattern."""
    async with capture_logs() as logs:
        logger = await (
            LoggerBuilder()
            .with_redaction(patterns=[r"(?i).*ssn.*"])
            .build_async()
        )
        await logger.info("User data", user_ssn="123-45-6789")

    assert "123-45-6789" not in logs.text


@pytest.mark.asyncio
async def test_url_credentials_stripped():
    """Verify URL credentials are removed by default."""
    async with capture_logs() as logs:
        logger = await LoggerBuilder().build_async()
        await logger.info(
            "Database URL",
            url="postgres://admin:supersecret@db.example.com/app",
        )

    # Credentials should be stripped
    assert "supersecret" not in logs.text
    assert "admin:" not in logs.text
    # Host should remain
    assert "db.example.com" in logs.text

CI/CD Redaction Verification

Add a test that fails if sensitive patterns appear in logs:

FORBIDDEN_PATTERNS = [
    r"\b[A-Za-z0-9]{32,}\b",  # Long tokens
    r"\b\d{3}-\d{2}-\d{4}\b",  # SSN format
    r"password\s*[:=]\s*\S+",  # password=value
]


@pytest.mark.asyncio
async def test_no_sensitive_patterns_in_logs():
    """Fail if any forbidden pattern appears in log output."""
    import re

    async with capture_logs() as logs:
        # Run your application code here
        pass

    for pattern in FORBIDDEN_PATTERNS:
        matches = re.findall(pattern, logs.text, re.IGNORECASE)
        assert not matches, f"Sensitive pattern found: {pattern} -> {matches}"

Auditing What Gets Redacted

To see what redaction is happening, enable diagnostics:

from fapilog import LoggerBuilder

logger = await (
    LoggerBuilder()
    .with_redaction(fields=["password"])
    .with_diagnostics(enabled=True)
    .build_async()
)

Diagnostics will log warnings if redaction encounters issues (max depth exceeded, unredactable fields, etc.).

Performance Guardrails

Redactors have built-in limits to prevent performance issues with deeply nested or large objects:

Setting

Default

Purpose

max_depth

16

Maximum nesting level to traverse

max_keys_scanned

1000

Maximum keys to examine

Configure these via with_redaction():

logger = await (
    LoggerBuilder()
    .with_redaction(fields=["password"], max_depth=32, max_keys=5000)
    .build_async()
)

Declaring Sensitive Data at Log Time

Instead of relying solely on redactor configuration, you can mark data as sensitive when you log it. Pass a sensitive= dict and fapilog masks the values at envelope construction time, before the event reaches the queue or any sink:

# Developer declares intent — values are masked immediately
await logger.info(
    "User signup",
    sensitive={"email": "alice@example.com", "ssn": "123-45-6789"},
    plan="free",
)

# What appears in logs
{"message": "User signup", "data": {"sensitive": {"email": "***", "ssn": "***"}, "plan": "free"}}

pii= is an alias for teams that prefer that term:

await logger.info("Payment processed", pii={"card_number": "4111-1111-1111-1111"})

Both keywords route to data.sensitive in the envelope. Nested dicts and lists are recursively masked:

await logger.info(
    "Checkout",
    sensitive={"card": {"number": "4111-1111-1111-1111", "cvv": "123"}},
)
# data.sensitive.card.number == "***", data.sensitive.card.cvv == "***"

When to Use Each Approach

Approach

Best For

sensitive= / pii=

Data the developer knows is sensitive at log time

with_redaction(fields=...)

Safety net for fields developers might forget to mark

with_redaction(preset=...)

Compliance-driven blanket coverage

These are complementary. sensitive= gives developer-declared intent; redactors remain the safety net for fields not explicitly marked.

Blocking High-Risk Fields

Some fields should never appear in logs — request bodies, raw payloads, and response dumps can contain arbitrary user data. The field_blocker redactor replaces these values entirely:

from fapilog import LoggerBuilder

logger = await (
    LoggerBuilder()
    .with_redaction(block_fields=["body", "request_body", "payload", "raw"])
    .build_async()
)

# What you log
await logger.info("Request received", body='{"ssn": "123-45-6789"}', method="POST")

# What appears in logs
{"message": "Request received", "data": {"body": "[REDACTED:HIGH_RISK_FIELD]", "method": "POST"}}

Each blocked field emits a policy-violation diagnostic, so you can monitor violations via the fapilog_policy_violations_total metric.

The hardened preset enables field_blocker by default with a sensible blocklist. To exempt a specific field, use allowed_fields in the redactor config:

from fapilog import Settings

settings = Settings(
    redactor_config={
        "field_blocker": {
            "blocked_fields": ["body", "payload"],
            "allowed_fields": ["body"],  # Exempt "body" from blocking
        },
    },
)

Truncating Long Strings

Large string values (stack traces, serialized payloads, base64 blobs) can bloat log events. The string_truncate redactor caps string length and appends a [truncated] marker:

from fapilog import LoggerBuilder

logger = await (
    LoggerBuilder()
    .with_redaction(max_string_length=500)
    .build_async()
)

# What you log
await logger.info("Error details", traceback="..." * 1000)

# What appears in logs — string truncated to 500 chars + marker
{"message": "Error details", "data": {"traceback": "......[truncated]"}}

This redactor is disabled by default (max_string_length=None). Set a value to enable it. The truncation happens after all other redactors, so masked values are not affected.

Going Deeper