diff --git a/src/Sentry/Internal/DataCollection/KeyValueFilterBehavior.cs b/src/Sentry/Internal/DataCollection/KeyValueFilterBehavior.cs
new file mode 100644
index 0000000000..72d71748d6
--- /dev/null
+++ b/src/Sentry/Internal/DataCollection/KeyValueFilterBehavior.cs
@@ -0,0 +1,49 @@
+namespace Sentry.Internal.DataCollection;
+
+///
+/// How a key-value collection (HTTP headers, cookies, URL query parameters) should be collected, per the
+/// DataCollection spec: https://develop.sentry.dev/sdk/foundations/client/data-collection/
+///
+internal enum KeyValueFilterMode
+{
+ /// Neither keys nor values are collected.
+ Off,
+
+ /// All keys are collected; values of sensitive keys are replaced with "[Filtered]".
+ DenyList,
+
+ /// All keys are collected; only values of allow-listed, non-sensitive keys are kept.
+ AllowList
+}
+
+///
+/// A plus the terms that parameterize it: extra deny terms in
+/// mode, allowed key terms in
+/// mode. Terms match key names as case-insensitive substrings. Internal precursor to the public behavior type that
+/// ships with the DataCollection option.
+///
+internal readonly struct KeyValueFilterBehavior
+{
+ private readonly string[]? _terms;
+
+ private KeyValueFilterBehavior(KeyValueFilterMode mode, string[]? terms)
+ {
+ Mode = mode;
+ _terms = terms;
+ }
+
+ public KeyValueFilterMode Mode { get; }
+
+ public string[] Terms => _terms ?? [];
+
+ public static KeyValueFilterBehavior Off => new(KeyValueFilterMode.Off, null);
+
+ /// The default behavior: collect everything, replacing values of sensitive keys.
+ public static KeyValueFilterBehavior DenyList => new(KeyValueFilterMode.DenyList, null);
+
+ public static KeyValueFilterBehavior DenyListWith(params string[] extraDenyTerms) =>
+ new(KeyValueFilterMode.DenyList, extraDenyTerms);
+
+ public static KeyValueFilterBehavior AllowList(params string[] allowedTerms) =>
+ new(KeyValueFilterMode.AllowList, allowedTerms);
+}
diff --git a/src/Sentry/Internal/DataCollection/SensitiveDataFilter.cs b/src/Sentry/Internal/DataCollection/SensitiveDataFilter.cs
new file mode 100644
index 0000000000..fd40ffc669
--- /dev/null
+++ b/src/Sentry/Internal/DataCollection/SensitiveDataFilter.cs
@@ -0,0 +1,148 @@
+namespace Sentry.Internal.DataCollection;
+
+///
+/// Filters automatically collected key-value data (HTTP headers, cookies, URL query parameters) so that values of
+/// sensitive keys are replaced with "[Filtered]" before being sent to Sentry, per the DataCollection spec:
+/// https://develop.sentry.dev/sdk/foundations/client/data-collection/
+/// Key names are always preserved; only values are replaced. Only applies to automatically collected data —
+/// data explicitly set by the user is never filtered.
+///
+internal static class SensitiveDataFilter
+{
+ internal const string FilteredValue = PiiExtensions.RedactedText;
+
+ ///
+ /// Case-insensitive substrings identifying sensitive key names, from the canonical denylist in the spec,
+ /// plus "set-cookie"/"cookie" so cookie headers are treated as sensitive wherever individual cookie
+ /// pairs cannot be extracted.
+ ///
+ internal static readonly string[] SensitiveKeyTerms =
+ [
+ "auth",
+ "token",
+ "secret",
+ "session",
+ "password",
+ "passwd",
+ "pwd",
+ "key",
+ "jwt",
+ "bearer",
+ "sso",
+ "saml",
+ "csrf",
+ "xsrf",
+ "credentials",
+ "sid",
+ "identity",
+ "set-cookie",
+ "cookie",
+ ];
+
+ ///
+ /// Extra substrings matched only against individual Cookie / Set-Cookie names (not header names), covering
+ /// common session secrets that do not match (e.g. "connect.sid") without
+ /// false positives on arbitrary HTTP headers. Cookie-only terms already implied by a
+ /// match (e.g. "oauth", "id_token") are omitted.
+ ///
+ internal static readonly string[] SensitiveCookieNameTerms =
+ [
+ // Express / Connect default session cookie
+ ".sid",
+ // Opaque session ids (PHPSESSID, ASPSESSIONID*, *sessid*, ...)
+ "sessid",
+ // "Remember me" tokens
+ "remember",
+ // OIDC / OAuth auxiliary ("oauth*" is covered by "auth")
+ "oidc",
+ "pkce",
+ "nonce",
+ // RFC 6265bis high-security cookie name prefixes
+ "__secure-",
+ "__host-",
+ // Load balancer / CDN sticky-session cookies (opaque routing tokens)
+ "awsalb",
+ "awselb",
+ "akamai",
+ // BaaS / IdP session cookies (names often omit "session")
+ "__stripe",
+ "cognito",
+ "firebase",
+ "supabase",
+ "sb-",
+ // Step-up / MFA cookies
+ "mfa",
+ "2fa",
+ ];
+
+ ///
+ /// Key-name substrings that identify end users (IP addresses, forwarding chains). Not filtered by default;
+ /// used to extend deny lists when replicating SendDefaultPii = false behavior (GDPR terms per the spec).
+ ///
+ internal static readonly string[] PiiKeyTerms = ["forwarded", "-ip", "remote-", "via", "-user"];
+
+ internal static bool IsSensitiveKey(string key) => MatchesAny(key, SensitiveKeyTerms);
+
+ ///
+ /// Filters a single value according to . Returns the value unchanged, or
+ /// in its place. Must not be called in mode
+ /// (in off mode nothing should be collected at all).
+ ///
+ internal static string FilterValue(string key, string value, KeyValueFilterBehavior behavior,
+ string[]? additionalDenyTerms = null)
+ {
+ Debug.Assert(behavior.Mode != KeyValueFilterMode.Off,
+ "FilterValue must not be called in Off mode - collect nothing instead.");
+
+ if (MatchesAny(key, SensitiveKeyTerms) ||
+ (additionalDenyTerms is not null && MatchesAny(key, additionalDenyTerms)))
+ {
+ return FilteredValue;
+ }
+
+ return behavior.Mode switch
+ {
+ KeyValueFilterMode.DenyList => MatchesAny(key, behavior.Terms) ? FilteredValue : value,
+ KeyValueFilterMode.AllowList => MatchesAny(key, behavior.Terms) ? value : FilteredValue,
+ _ => value
+ };
+ }
+
+ ///
+ /// Filters key-value data according to . Key names are always preserved; values are
+ /// either kept or replaced with . In mode an
+ /// empty dictionary is returned. are extra sensitive terms applied in
+ /// every mode, beyond the built-in denylist (e.g. when filtering cookies).
+ ///
+ internal static Dictionary FilterKeyValueData(
+ IEnumerable> data,
+ KeyValueFilterBehavior behavior,
+ string[]? additionalDenyTerms = null)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (behavior.Mode == KeyValueFilterMode.Off)
+ {
+ return result;
+ }
+
+ foreach (var (key, value) in data)
+ {
+ result[key] = FilterValue(key, value, behavior, additionalDenyTerms);
+ }
+
+ return result;
+ }
+
+ private static bool MatchesAny(string key, string[] terms)
+ {
+ foreach (var term in terms)
+ {
+ if (key.Contains(term, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/test/Sentry.Tests/Internals/DataCollection/SensitiveDataFilterTests.cs b/test/Sentry.Tests/Internals/DataCollection/SensitiveDataFilterTests.cs
new file mode 100644
index 0000000000..35e2fb65be
--- /dev/null
+++ b/test/Sentry.Tests/Internals/DataCollection/SensitiveDataFilterTests.cs
@@ -0,0 +1,254 @@
+using Sentry.Internal.DataCollection;
+
+namespace Sentry.Tests.Internals.DataCollection;
+
+public class SensitiveDataFilterTests
+{
+ [Theory]
+ [InlineData("auth")]
+ [InlineData("token")]
+ [InlineData("secret")]
+ [InlineData("session")]
+ [InlineData("password")]
+ [InlineData("passwd")]
+ [InlineData("pwd")]
+ [InlineData("key")]
+ [InlineData("jwt")]
+ [InlineData("bearer")]
+ [InlineData("sso")]
+ [InlineData("saml")]
+ [InlineData("csrf")]
+ [InlineData("xsrf")]
+ [InlineData("credentials")]
+ [InlineData("sid")]
+ [InlineData("identity")]
+ [InlineData("set-cookie")]
+ [InlineData("cookie")]
+ public void IsSensitiveKey_CanonicalDenylistTerm_ReturnsTrue(string term)
+ {
+ SensitiveDataFilter.IsSensitiveKey(term).Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("Authorization")] // contains "auth"
+ [InlineData("X-Api-Key")] // contains "key"
+ [InlineData("PHPSESSID")] // contains "sid"
+ [InlineData("X-CSRF-TOKEN")]
+ [InlineData("Set-Cookie")]
+ public void IsSensitiveKey_KeyContainingTermInAnyCase_ReturnsTrue(string key)
+ {
+ SensitiveDataFilter.IsSensitiveKey(key).Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("User-Agent")]
+ [InlineData("Accept")]
+ [InlineData("Content-Type")]
+ [InlineData("traceparent")]
+ public void IsSensitiveKey_BenignKey_ReturnsFalse(string key)
+ {
+ SensitiveDataFilter.IsSensitiveKey(key).Should().BeFalse();
+ }
+
+ [Fact]
+ public void FilterKeyValueData_OffMode_ReturnsEmpty()
+ {
+ // Arrange
+ var data = new Dictionary
+ {
+ ["User-Agent"] = "TestAgent",
+ ["Authorization"] = "Bearer 123"
+ };
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.Off);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void FilterKeyValueData_DefaultBehavior_IsOff()
+ {
+ // The default struct value must be the most conservative mode
+ default(KeyValueFilterBehavior).Mode.Should().Be(KeyValueFilterMode.Off);
+ default(KeyValueFilterBehavior).Terms.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void FilterKeyValueData_DenyListMode_FiltersSensitiveValuesAndPreservesKeys()
+ {
+ // Arrange
+ var data = new Dictionary
+ {
+ ["User-Agent"] = "TestAgent",
+ ["Authorization"] = "Bearer 123",
+ ["X-API-KEY"] = "abc123"
+ };
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.DenyList);
+
+ // Assert
+ result.Should().BeEquivalentTo(new Dictionary
+ {
+ ["User-Agent"] = "TestAgent",
+ ["Authorization"] = "[Filtered]",
+ ["X-API-KEY"] = "[Filtered]"
+ });
+ }
+
+ [Fact]
+ public void FilterKeyValueData_DenyListModeWithExtraTerms_FiltersExtraTerms()
+ {
+ // Arrange
+ var data = new Dictionary
+ {
+ ["X-Forwarded-For"] = "203.0.113.7",
+ ["Accept"] = "application/json"
+ };
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.DenyListWith("forwarded"));
+
+ // Assert
+ result["X-Forwarded-For"].Should().Be("[Filtered]");
+ result["Accept"].Should().Be("application/json");
+ }
+
+ [Fact]
+ public void FilterKeyValueData_AllowListMode_OnlyAllowedKeysKeepValues()
+ {
+ // Arrange
+ var data = new Dictionary
+ {
+ ["User-Agent"] = "TestAgent",
+ ["Accept"] = "application/json",
+ ["X-Custom"] = "value"
+ };
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.AllowList("user-agent"));
+
+ // Assert
+ result.Should().BeEquivalentTo(new Dictionary
+ {
+ ["User-Agent"] = "TestAgent",
+ ["Accept"] = "[Filtered]",
+ ["X-Custom"] = "[Filtered]"
+ });
+ }
+
+ [Fact]
+ public void FilterKeyValueData_AllowListMode_BuiltInDenylistWins()
+ {
+ // Arrange: allow-listing a sensitive key must not expose its value
+ var data = new Dictionary
+ {
+ ["Authorization"] = "Bearer 123"
+ };
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.AllowList("authorization"));
+
+ // Assert
+ result["Authorization"].Should().Be("[Filtered]");
+ }
+
+ [Fact]
+ public void FilterKeyValueData_AllowListMode_MatchesSubstrings()
+ {
+ // Arrange
+ var data = new Dictionary
+ {
+ ["X-Tenant-Id"] = "acme",
+ ["X-Request-Id"] = "42"
+ };
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.AllowList("tenant"));
+
+ // Assert
+ result["X-Tenant-Id"].Should().Be("acme");
+ result["X-Request-Id"].Should().Be("[Filtered]");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void FilterKeyValueData_AdditionalDenyTerms_AppliedInEveryMode(bool useAllowList)
+ {
+ // Arrange: "remember" is a cookie-name term, not part of the built-in denylist
+ var data = new Dictionary
+ {
+ ["remember_me"] = "yes"
+ };
+ var behavior = useAllowList
+ ? KeyValueFilterBehavior.AllowList("remember_me")
+ : KeyValueFilterBehavior.DenyList;
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(
+ data, behavior, SensitiveDataFilter.SensitiveCookieNameTerms);
+
+ // Assert
+ result["remember_me"].Should().Be("[Filtered]");
+ }
+
+ [Theory]
+ [InlineData("connect.sid")]
+ [InlineData("PHPSESSID")]
+ [InlineData("remember_token")]
+ [InlineData("__Secure-next-auth.session-token")]
+ [InlineData("__Host-csrf")]
+ [InlineData("AWSALB")]
+ [InlineData("__stripe_mid")]
+ [InlineData("CognitoIdentityServiceProvider.foo")]
+ [InlineData("sb-access-token")]
+ [InlineData("mfa_pending")]
+ public void FilterKeyValueData_CommonSessionCookieNames_FilteredWithCookieTerms(string cookieName)
+ {
+ // Arrange
+ var data = new Dictionary { [cookieName] = "value" };
+
+ // Act
+ var result = SensitiveDataFilter.FilterKeyValueData(
+ data, KeyValueFilterBehavior.DenyList, SensitiveDataFilter.SensitiveCookieNameTerms);
+
+ // Assert
+ result[cookieName].Should().Be("[Filtered]");
+ }
+
+ [Fact]
+ public void FilterValue_BenignKeyDenyListMode_ReturnsValueUnchanged()
+ {
+ SensitiveDataFilter.FilterValue("Accept", "text/html", KeyValueFilterBehavior.DenyList)
+ .Should().Be("text/html");
+ }
+
+ [Theory]
+ [InlineData("X-Forwarded-For")]
+ [InlineData("Client-IP")]
+ [InlineData("Remote-Addr")]
+ [InlineData("Via")]
+ [InlineData("X-Real-User")]
+ public void FilterValue_PiiKeyTermsAsDenyTerms_Filtered(string key)
+ {
+ // The GDPR terms are not filtered by default but must work as extra deny terms (SendDefaultPii=false bridge)
+ SensitiveDataFilter.FilterValue(key, "value", KeyValueFilterBehavior.DenyList, SensitiveDataFilter.PiiKeyTerms)
+ .Should().Be("[Filtered]");
+ }
+
+ [Fact]
+ public void FilterValue_PiiKeyTermsNotPassed_NotFilteredByDefault()
+ {
+ SensitiveDataFilter.FilterValue("X-Forwarded-For", "203.0.113.7", KeyValueFilterBehavior.DenyList)
+ .Should().Be("203.0.113.7");
+ }
+
+ [Fact]
+ public void FilterKeyValueData_EmptyData_ReturnsEmpty()
+ {
+ SensitiveDataFilter.FilterKeyValueData([], KeyValueFilterBehavior.DenyList).Should().BeEmpty();
+ }
+}