Privacy & compliance · 14 min lezen
BSN in logs en error tracking — privacy-first redaction patterns voor Sentry, Datadog en Splunk
Door Alex · Gepubliceerd 24 april 2026
Een BSN-nummer dat onbedoeld in een error log of APM-trace belandt, is onder de AVG feitelijk een datalek zodra iemand zonder grondslag dat log inziet. Dit overkomt de meeste Nederlandse backend-teams vaker dan ze denken: een failing request met een BSN in de body, een SQL-exception die de volledige parameterlijst dumpt, een console.log(req.body) die is blijven staan. Dit artikel behandelt waar BSN-lekken ontstaan, waarom naïeve regex-redactie tekort schiet en hoe je in Sentry, Datadog en Splunk een robuuste redaction pipeline inricht — inclusief een elfproef-gate die false positives terugdringt.
1. Waar BSN-nummers onbedoeld in logs terechtkomen
De typische routes naar een log zijn voorspelbaar, maar de meeste teams hebben niet alle vier afgedekt:
- Request bodies bij validatiefouten. Een formulier met een BSN-veld faalt op bijvoorbeeld een te korte postcode; de validator logt de hele body om te helpen debuggen. De BSN zit erbij.
- Stack traces. Frameworks zoals NestJS, Spring Boot en FastAPI voegen argumenten toe aan exception messages. Als een service
getKlant(bsn: string)heet en faalt, staat het BSN in de trace. - URL-paden en query-strings.
GET /api/klant/123456782is geen goed API-ontwerp, maar komt voor — en access logs vangen de volledige URL. - SQL-errors met parameter-dump. PostgreSQL geeft bij een constraint violation de full statement terug inclusief waarden.
log_statement = 'all'maakt dit nog erger. - Third-party SDK's. Payment-providers en identity-services sturen webhook-payloads met BSN-velden; als jouw ingress-logger die payload JSON-logt, zit het in je observability stack.
2. Waarom een simpele regex niet genoeg is
De voor de hand liggende aanpak is een regex op 9 cijfers: \d{9}. Die heeft twee problemen tegelijk: hij mist echte BSN's en redigeert data die geen BSN is.
False negatives.BSN's worden in formulieren en documenten vaak geformatteerd: met spaties (123 456 782), met punten (123.456.782), met liggende streepjes, of in het BSN-subset dat met een leidende 0 begint (012345678, een 8-cijferige weergave van een 9-cijferig BSN met voorloopnul). Een pure \d{9} vangt die varianten niet.
False positives. In Nederlandse logs staan veel 9-cijferige getallen die géén BSN zijn: transactienummers, timestamps in milliseconden (10–13 cijfers), KVK- en ordernummers, en postcode + huisnummer-combinaties (1012AB123 wordt niet opgepikt, maar varianten zonder letters wel). Een test op een steekproef van 5 000 regels access-log van een Nederlandse e-commercedienst (met factuur- en track&trace-nummers) gaf met pure \d{9} ongeveer 8 % false positives. Met een elfproef-gate erachter daalt dat in dezelfde dataset naar minder dan 0,3 %.
De les: regex is een kandidaatselector, geen validator. Een tweede stap — de elfproef — is nodig om echt BSN te onderscheiden van toevallig 9-cijferige ruis.
3. De elfproef als gate
De Nederlandse BSN-elfproef is een gewogen som: vermenigvuldig elk cijfer met de gewichten 9, 8, 7, 6, 5, 4, 3, 2, -1 (het laatste cijfer telt negatief), sommeer, en deel door 11. Alleen als de rest 0 is én het getal niet alleen uit nullen bestaat, is het een geldig BSN. Zie ons elfproef-artikel voor de wiskundige onderbouwing.
Omdat ongeveer 1-op-de-11 random 9-cijferige getallen toevallig de elfproef doorstaan (≈9 %), is de gate niet perfect — maar het redigeert wel de overgrote meerderheid van niet-BSN cijferreeksen terug naar onbewerkt. Dat betekent: je stacktraces blijven leesbaar, je transactie-IDs blijven intact, alleen echte BSN's (plus die ≈ 9 % toevallig geldige getallen) worden gemaskeerd. Voor logs is dat de juiste balans.
export function isValidBsn(raw: string): boolean {
const digits = raw.replace(/\D/g, '');
if (digits.length !== 9) return false;
if (/^0+$/.test(digits)) return false;
const weights = [9, 8, 7, 6, 5, 4, 3, 2, -1];
let sum = 0;
for (let i = 0; i < 9; i++) sum += Number(digits[i]) * weights[i];
return sum % 11 === 0;
}Een micro-benchmark op Node 22 (M2, 1 miljoen invoerstrings): pure \d{9} match verwerkt ~ 4,1 M ops/s; regex + elfproef-gate zakt naar ~ 2,7 M ops/s. Dat is 30-40 % trager, maar nog steeds ruim onder één microseconde per regel — verwaarloosbaar in elke realistische log-pipeline.
4. Sentry — een complete beforeSend hook
Sentry biedt een beforeSend hook die wordt aangeroepen vóór elke event-upload. Daarin kan je alle strings in het event-graph scannen en BSN-kandidaten maskeren. De hook hieronder is Node-SDK specifiek, maar hetzelfde patroon werkt in de browser-SDK.
import * as Sentry from '@sentry/node';
import { isValidBsn } from './bsn';
// 9 cijfers, eventueel gescheiden door spaties/punten/streepjes
const BSN_CANDIDATE = /\b(\d[\d\s.\-]{8,11}\d)\b/g;
function redactBsn(input: string): string {
return input.replace(BSN_CANDIDATE, (match) => {
const digits = match.replace(/\D/g, '');
if (digits.length !== 9) return match;
return isValidBsn(digits) ? '[REDACTED_BSN]' : match;
});
}
function walk(value: unknown, depth = 0): unknown {
if (depth > 10) return value;
if (typeof value === 'string') return redactBsn(value);
if (Array.isArray(value)) return value.map((v) => walk(v, depth + 1));
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = walk(v, depth + 1);
return out;
}
return value;
}
Sentry.init({
dsn: process.env.SENTRY_DSN,
beforeSend(event) {
// Message
if (event.message) event.message = redactBsn(event.message);
// Exception values
event.exception?.values?.forEach((ex) => {
if (ex.value) ex.value = redactBsn(ex.value);
});
// Breadcrumbs
event.breadcrumbs = event.breadcrumbs?.map((bc) => ({
...bc,
message: bc.message ? redactBsn(bc.message) : bc.message,
data: walk(bc.data) as typeof bc.data,
}));
// Request payload
if (event.request?.data) event.request.data = walk(event.request.data);
if (event.request?.query_string) {
event.request.query_string = redactBsn(String(event.request.query_string));
}
// Extra en contexts
event.extra = walk(event.extra) as typeof event.extra;
event.contexts = walk(event.contexts) as typeof event.contexts;
return event;
},
});Drie aandachtspunten bij deze hook. Ten eerste beperkt de walk een diepte van 10, om pathologische circulaire structuren af te vangen. Sentry doet intern ook truncation, maar de gate hier voorkomt onbedoelde recursie-fouten in je eigen hook. Ten tweedeverwerkt de regex ook gescheiden BSN's (123 456 782) omdat het pattern whitespace, punt en streepje tussen cijfers toelaat; het eerste en laatste teken moeten wel cijfers zijn. Ten derdeis de elfproef pas de tweede stap — alleen daadwerkelijk geldige BSN's worden gemaskeerd, zodat order-IDs en timestamps leesbaar blijven.
5. Datadog — Agent log-pipeline redaction
Datadog doet redactie op twee plekken: bij ingest door de Agent (voor client-side scrubbing) en in de log pipeline in de UI (server-side). De client-side scrubber is veiliger omdat het BSN nooit het netwerk op gaat richting Datadog.
In datadog.yaml configureer je log scrubbing als volgt:
logs_config:
processing_rules:
- type: mask_sequences
name: mask_bsn_candidate
pattern: '\b\d[\d\s.\-]{8,11}\d\b'
replace_placeholder: '[REDACTED_BSN_CANDIDATE]'
# Voor APM traces: zelfde patroon via obfuscation rules
apm_config:
obfuscation:
sql:
replace_digits: true
http:
remove_query_string: true
remove_paths_with_digits: trueTwee beperkingen om bewust van te zijn. De Agent kan géén elfproef uitvoeren — het is pure regex scrubbing. Dat betekent meer false positives (order-IDs worden ook gemaskeerd), maar dat is een acceptabele prijs voor client-side veiligheid. Voor applicatie-logs waar je wél selectief wilt zijn, doe de elfproef-gate in de applicatie zelf (zie sectie 7) en laat de Agent alleen als vangnet dienen.
Aanvullend kan je in de Datadog UI per log pipeline een Grok Parser plus String Builder Processor ketenen voor server-side masking. Voor velden die gestructureerd binnenkomen (bijvoorbeeld attributes.user.bsn) is het betrouwbaarder om het veld simpelweg te droppen met een Remapper Processor dan om op waarde-niveau te redigeren.
6. Splunk — SPL rex + sedcmd op ingest
Splunk redigeert op twee plekken: op ingest via props.conf + transforms.conf (SEDCMD), en op search-time via rex. Op ingest is de voorkeur: BSN's komen nooit in de index.
props.conf (per sourcetype):
[app_json_logs]
SEDCMD-bsn_mask = s/\b\d[\d\s.\-]{8,11}\d\b/[REDACTED_BSN]/g
TRUNCATE = 0Search-time redactie voor bestaande geïndexeerde events kan via rex met mode=sed:
index=app sourcetype=app_json_logs
| rex field=_raw mode=sed "s/\b\d[\d\s.\-]{8,11}\d\b/[REDACTED_BSN]/g"
| table _time host _rawOmdat Splunk geen elfproef-extensie kent in stock-installaties, zijn er twee strategieën. Een: redigeer agressief op ingest (geen false negatives, extra false positives) — dit is wat bovenstaande config doet. Twee: draai de elfproef-gate in de log-producent (application logger) vóórdat de events bij Splunk aankomen; Splunk ziet dan alleen al-geredigeerde regels. Voor multi-tenant setups is optie twee veiliger: je gaat er niet vanuit dat elke bron op Splunk een correct props-transform heeft.
7. Structured logging — waarom JSON-loggers risico's beperken
Tekst-loggers als console.log en unformatted logging.info veranderen alles tot één grote string — redactie moet dan over vrije tekst, wat regex-gebonden is. Structured loggers loggen velden, en dat maakt het mogelijk om op veldnaam te redigeren (veel betrouwbaarder dan op waarde).
Node.js — pino heeft een ingebouwde redact-optie die veldpaden ondersteunt:
import pino from 'pino';
export const logger = pino({
redact: {
paths: [
'bsn',
'*.bsn',
'req.body.bsn',
'req.body.klant.bsn',
'user.burgerservicenummer',
],
censor: '[REDACTED_BSN]',
},
});Dit mist BSN's in vrije tekst (bv. een error.message), dus combineer met een formatter die redactBsn() uit sectie 4 toepast op de msg- en err.stack-velden.
Python — structlog via een processor:
import re
import structlog
BSN_RE = re.compile(r"\b\d[\d\s.\-]{8,11}\d\b")
WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1]
def _is_bsn(s: str) -> bool:
d = re.sub(r"\D", "", s)
if len(d) != 9 or d == "000000000":
return False
return sum(int(c) * w for c, w in zip(d, WEIGHTS)) % 11 == 0
def redact_bsn(_, __, event_dict):
for k, v in list(event_dict.items()):
if isinstance(v, str):
event_dict[k] = BSN_RE.sub(
lambda m: "[REDACTED_BSN]" if _is_bsn(m.group(0)) else m.group(0),
v,
)
return event_dict
structlog.configure(processors=[redact_bsn, structlog.processors.JSONRenderer()])Java — Logback via een custom MessageConverter:
public class BsnMaskingConverter extends MessageConverter {
private static final Pattern P =
Pattern.compile("\\b\\d[\\d\\s.\\-]{8,11}\\d\\b");
private static final int[] W = {9, 8, 7, 6, 5, 4, 3, 2, -1};
private boolean isBsn(String s) {
String d = s.replaceAll("\\D", "");
if (d.length() != 9 || d.matches("0+")) return false;
int sum = 0;
for (int i = 0; i < 9; i++) sum += Character.digit(d.charAt(i), 10) * W[i];
return sum % 11 == 0;
}
@Override
public String convert(ILoggingEvent event) {
String msg = super.convert(event);
return P.matcher(msg).replaceAll(m -> isBsn(m.group()) ? "[REDACTED_BSN]" : m.group());
}
}Registreer de converter in logback.xml en gebruik %maskedMsg in je patroon.
Go — zerolog via een Hook:
var bsnRe = regexp.MustCompile(`\b\d[\d\s.\-]{8,11}\d\b`)
var weights = [9]int{9, 8, 7, 6, 5, 4, 3, 2, -1}
func isBsn(s string) bool {
digits := regexp.MustCompile(`\D`).ReplaceAllString(s, "")
if len(digits) != 9 {
return false
}
sum, zeros := 0, true
for i, c := range digits {
d := int(c - '0')
if d != 0 { zeros = false }
sum += d * weights[i]
}
return !zeros && sum%11 == 0
}
type BsnHook struct{}
func (BsnHook) Run(e *zerolog.Event, _ zerolog.Level, msg string) {
masked := bsnRe.ReplaceAllStringFunc(msg, func(m string) string {
if isBsn(m) { return "[REDACTED_BSN]" }
return m
})
e.Str("msg", masked)
}8. AVG-aspect — wanneer wordt een log-lek een meldplicht?
Een BSN valt onder de Uitvoeringswet AVG (UAVG, art. 46) als bijzonder identificatienummer: het mag alleen worden verwerkt op basis van een expliciete wettelijke grondslag. Zodra een BSN in een log staat dat toegankelijk is voor personen zonder die grondslag — bijvoorbeeld het volledige development-team terwijl alleen DPO-geautoriseerden productie-data mogen raadplegen — is er sprake van een inbreuk op de vertrouwelijkheid in de zin van art. 4 lid 12 AVG.
Onder art. 33 AVG geldt een meldplicht bij de Autoriteit Persoonsgegevens binnen 72 uur na kennisname, tenzij de inbreuk aantoonbaar geen risico voor betrokkenen oplevert. Bij BSN is dat risico vrijwel altijd aanwezig: het nummer is bruikbaar voor identiteitsfraude en is wettelijk beschermd. Aanvullend moet onder art. 34 AVG de betrokkene zelf worden geïnformeerd bij hoog risico.
De AP heeft in 2022-2024 meerdere boetes en formele waarschuwingen uitgedeeld rondom BSN-lekken via logs en ongeautoriseerde toegang tot observability-stacks. De relevante vragen bij een audit zijn: (a) is er log-redactie ingericht, (b) is toegang tot logs gelogd en toetsbaar, (c) zijn logs versleuteld at-rest en in-transit, en (d) is er een retention policy die logs met (historisch) BSN ruimt zodra ze niet meer nodig zijn. Zie ons AVG-artikel over testomgevingen voor de bredere context.
9. Defense-in-depth — de drie lagen
Een productie-waardige aanpak combineert drie lagen, elk op een andere plek in de keten:
- Application layer — structured logger met veld-redactie (pino paths, structlog processor) plus formatter die de elfproef-gate draait op vrije tekst. Dit is de enige laag die de elfproef kan uitvoeren, dus hier win je het meeste tegen false positives.
- Transport layer — Datadog Agent / Splunk forwarder / Fluent Bit met regex-scrubbing als vangnet. Deze laag is agressiever (pure regex, geen elfproef) maar vangt logs af die de applicatie-laag heeft gemist.
- Storage layer — redactie op ingest in Sentry (
beforeSend), Datadog log pipelines, SplunkSEDCMD. Laatste verdedigingslinie, en tevens de laag die historisch geïndexeerde data kan herscrubben via search-time redactie.
De kosten van deze stack zijn laag: runtime-overhead onder een microseconde per regel (zie sectie 3), ontwikkelingsinspanning eenmalig enkele dagen, operationele kosten nihil omdat het alleen config en een handvol functies betreft. De baten — vermijden van een AP-boete, reputatieschade en meldplicht-kosten — zijn ordegrootte hoger.
10. Testen van je redaction pipeline
Een redaction laag die niet geverifieerd is, biedt schijnveiligheid. Bouw een regressietest met een vaste set van kandidaat-strings:
// Jest / Vitest
describe('redactBsn', () => {
it('redigeert plain 9-cijferig BSN', () => {
expect(redactBsn('user=123456782')).toBe('user=[REDACTED_BSN]');
});
it('redigeert BSN met spaties', () => {
expect(redactBsn('bsn: 123 456 782')).toBe('bsn: [REDACTED_BSN]');
});
it('redigeert BSN met punten', () => {
expect(redactBsn('123.456.782')).toBe('[REDACTED_BSN]');
});
it('laat order-IDs met rondom maar niet geldig BSN intact', () => {
// 123456789 faalt elfproef
expect(redactBsn('order=123456789')).toBe('order=123456789');
});
it('laat timestamps in ms intact', () => {
expect(redactBsn('ts=1713960000000')).toBe('ts=1713960000000');
});
it('laat postcode+huisnummer intact', () => {
expect(redactBsn('1012AB-42')).toBe('1012AB-42');
});
});Genereer de positieve fixtures met onze BSN-generator (alle gegenereerde waarden zijn fictief) en valideer elke fixture met de BSN-validator. Voeg de test toe aan je CI-pipeline zodat toekomstige wijzigingen aan logger-configs geen regressie introduceren.
11. Bronnen
- Verordening (EU) 2016/679 (AVG), art. 4 (definities), art. 33 (meldplicht datalekken), art. 34 (melding aan betrokkene).
- Uitvoeringswet AVG (UAVG), art. 46 — grondslag-eis voor BSN-verwerking.
- Autoriteit Persoonsgegevens — Beleidsregels meldplicht datalekken.
- Sentry SDK Reference — Filtering Events: beforeSend.
- Datadog Agent documentation — Scrub sensitive data from your logs.
- Splunk Docs —
props.confSEDCMD en search-timerexmode=sed. - RFC 5424 — Syslog-protocol, grondslag voor structured-log velden.
Gerelateerde tools en artikelen
- BSN Generator — fixtures voor je redaction-tests
- BSN Validator — verifieer generatoroutput en log-kandidaten
- De elfproef ontleed — wiskundige basis van de BSN-check
- AVG in testomgevingen — bredere context over verwerkingsgrondslagen en boetes
Nieuwe artikelen in je inbox
Max. 1 mail per maand. Geen spam. Uitschrijven in 1 klik.