Data classification labels in code: make privacy policies follow fields
For developers building systems that handle personal, financial, or regulated data, this guide shows how to attach classification labels to fields in code so retention, logging, masking, and access policies can execute automatically. You’ll see where labels live in a real request/data flow, how they propagate, what they replace, and the operational trade-offs before you commit.
TL;DR — Data classification labels in code are field-level tags like
public,internal,pii, orrestrictedattached to schemas, models, or events so downstream systems can enforce masking, retention, access, and logging rules without every service re-implementing policy logic. The most effective starting point is to put labels in your canonical schema definitions first, then wire two concrete enforcement points: log redaction and data export/serialization. Reading time: ~7 min
What it is and where it sits
This is not "write a policy document and hope engineers remember it." It is a design pattern: put machine-readable labels on data fields in code, then have libraries, middleware, pipelines, and storage jobs read those labels and apply policy automatically.
In practice, the labels usually live in one of three places:
- your API schema: JSON Schema, OpenAPI extensions, Protobuf field options, Avro schema metadata
- your domain model: ORM model metadata, struct tags, annotations, decorators
- your event schema: Kafka/queue payload schema, CDC schema, warehouse model metadata
The labels are consumed by:
- serializers deciding whether to include or mask fields
- logging middleware deciding what to redact before writing logs
- ETL/export jobs deciding what can leave the trust boundary
- retention/deletion jobs deciding what gets purged first
- access-control layers deciding which roles can read which fields
- test/static-analysis tooling checking that sensitive fields are not used in unsafe sinks
What it replaces:
- ad hoc
if field == "ssn"checks scattered across services - separate spreadsheets mapping columns to sensitivity classes
- one-off regex redaction in log pipelines after sensitive data already leaked
- policy decisions based only on table names or service boundaries
Where it lives in a request/data flow: close to the schema where the field is defined, then copied into runtime metadata or generated artifacts.
View as text
[Client] ---- 1. POST /signup ----> [API Service] [API Service] ---- 2. validate via schema ----> [Schema Metadata] [API Service] ---- 3. write labeled fields ----> [DB] [API Service] ---- 4. emit labeled event ----> [Queue] [API Service] ---- 5. structured log ----> [Log Pipeline] [Log Pipeline] ---- 6. redact by label ----> [Log Store] [Worker] <---- 7. consume event + labels ---- [Queue] [Worker] ---- 8. export allowed fields ----> [Analytics/Partner]
The architectural point is simple: policy should follow the field, not the service. If email is labeled pii.contact, then the API, worker, logger, and export job can all make the same decision from the same metadata.
How it actually works
Use one realistic example: a signup flow with fields email, password, birth_date, marketing_opt_in, and country.
Assume your canonical schema is JSON Schema stored in the application repo. Each field gets a classification label in an extension key such as x-classification. The exact key name is your choice; consistency matters more than the name.
Step 1: define labels in the canonical schema
You define labels where the fields are introduced:
email:pii.contactpassword:secret.authenticationbirth_date:pii.sensitivemarketing_opt_in:internal.preferencecountry:public
These labels are not the policy themselves. They are the selectors that policy code uses.
Step 2: load schema metadata at runtime
At service startup, the app loads the schema and builds a field-to-label map. In a typed service, this is often generated code. In a dynamic service, it may be a JSON file loaded into memory.
The runtime object usually looks like this:
{
"UserSignup.email": ["pii.contact"],
"UserSignup.password": ["secret.authentication"],
"UserSignup.birth_date": ["pii.sensitive"],
"UserSignup.marketing_opt_in": ["internal.preference"],
"UserSignup.country": ["public"]
}
Step 3: enforce at write time and log time
Request comes in. Validation uses the schema. Before logging the request body, middleware checks each field label.
Typical policy mapping:
public: log value normallyinternal.*: log only in debug builds or internal environmentspii.*: redact or hash before loggingsecret.*: never log; reject attempts to serialize to logs
So the incoming request body:
{
"email": "alice@example.com",
"password": "correct-horse-battery-staple",
"birth_date": "1990-04-12",
"marketing_opt_in": true,
"country": "DE"
}
becomes a structured log event like:
{
"event": "signup_request",
"email": "sha256:ff8d9819fc...",
"password": "[REDACTED]",
"birth_date": "[REDACTED]",
"marketing_opt_in": true,
"country": "DE"
}
If your redactor is misconfigured, the failure mode should be loud. Do not silently continue if a secret.* field reaches a logger sink. Crash the log call, increment a metric, or fail the request in high-assurance systems.
A realistic error shape from application logs is:
{
"level": "error",
"msg": "classification policy violation",
"field": "password",
"label": "secret.authentication",
"sink": "app_logger",
"action": "blocked"
}
Step 4: persist and propagate labels with events
When the service writes to the database, you usually do not store the label in every row unless you need row/column-level policy evaluation inside the database. More commonly, labels stay in schema metadata and are re-applied by code that knows the model.
For events and exports, include either:
- the schema version and let consumers look up labels from the registry, or
- explicit field metadata in the message envelope if consumers are loosely coupled
The worker consuming the signup event wants to send analytics. Its export policy says public and internal.preference can go to analytics, pii.* and secret.* cannot.
So the outbound analytics payload is reduced to:
{
"marketing_opt_in": true,
"country": "DE",
"schema": "UserSignup@v3"
}
Step 5: handle deletion and retention
Later, a retention job looks for fields labeled pii.* older than the allowed window and deletes or nulls them first. That gives you field-level retention without deleting the entire business record.
This is where labels earn their keep: one taxonomy drives logging, export, and retention. Without labels, each system keeps its own list and they drift.
When to use it (and when not to)
Use this when the same data moves through multiple services, logs, queues, and exports, and you need consistent privacy controls.
| Scenario | Recommendation |
|---|---|
| Single service, small team, little sensitive data, no external exports | You probably do not need a full labeling system. Start with explicit redaction in serializers and logs. |
| Multiple services sharing user/customer data | Use schema-level labels now. Drift will appear quickly otherwise. |
| You send data to analytics vendors, partners, or data warehouses | Strong yes. Labels should gate export paths. |
| You need different retention rules for different fields in the same record | Use labels; table-level retention is too coarse. |
| You only need role-based API authorization, not privacy controls | Labels may be overkill unless you also want field masking and logging controls. |
| Legacy monolith with no schema discipline | Start at API DTOs or ORM models, not everywhere at once. |
| Highly regulated environment with audit requirements | Use labels plus policy decision logs; labels alone are not enough. |
You probably do not need this if:
- all sensitive handling happens in one process and one database
- you can enumerate the 5-10 sensitive fields manually and they rarely change
- your immediate problem is just "stop logging passwords"; solve that directly first
- your org cannot maintain a stable taxonomy; a bad label system is worse than none
Trade-offs
Every benefit costs something.
- Consistent policy across services → costs taxonomy governance. Someone has to define what
pii,sensitive,restricted, andsecretactually mean and keep that stable. - Less duplicated redaction logic → costs framework plumbing. You need schema loaders, generated metadata, middleware hooks, and tests.
- Safer exports and logs → costs developer friction. Engineers will hit blocked serialization paths and need escape hatches for legitimate cases.
- Field-level retention and deletion → costs more complex data lifecycle jobs. Deleting one field while preserving record integrity is harder than dropping rows.
- Better auditability → costs operational overhead. You need metrics like "redaction applied," "policy violation blocked," and schema-version coverage.
- Portability across systems → costs lowest-common-denominator labels. If you want labels to work in app code, queues, warehouses, and DBs, your taxonomy must stay simple.
- Reduced accidental leakage → costs false positives and false negatives. Over-classify and teams bypass the system; under-classify and you leak data.
Lock-in risk is mostly self-inflicted. If you encode labels in generic schema metadata and plain code annotations, you can move platforms later. If you bury classification only in one vendor’s catalog or DLP product, migration gets painful.
Latency impact is usually small if labels are loaded in memory and checked during serialization. It becomes noticeable when every decision requires a remote policy call. Prefer local enforcement for hot paths; reserve remote policy engines for high-value authorization decisions.
In practice
Example 1: JSON Schema with field labels
{
"$id": "https://example.internal/schemas/user-signup.json",
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"x-classification": ["pii.contact"]
},
"password": {
"type": "string",
"minLength": 12,
"x-classification": ["secret.authentication"]
},
"birth_date": {
"type": "string",
"format": "date",
"x-classification": ["pii.sensitive"]
},
"marketing_opt_in": {
"type": "boolean",
"x-classification": ["internal.preference"]
},
"country": {
"type": "string",
"minLength": 2,
"maxLength": 2,
"x-classification": ["public"]
}
},
"required": ["email", "password", "country"]
}
This puts labels in the schema your service already validates against. Gotcha: if you generate clients or validators, confirm your tooling preserves custom extension keys like x-classification; some codegen pipelines drop unknown metadata unless configured not to.
Example 2: Node.js redaction middleware driven by labels
import fs from "node:fs";
import crypto from "node:crypto";
const schema = JSON.parse(fs.readFileSync("./schemas/user-signup.json", "utf8"));
const labelsByField = Object.fromEntries(
Object.entries(schema.properties).map(([name, def]) => [name, def["x-classification"] || ["internal"]])
);
function redactValue(value, labels) {
if (labels.some((l) => l.startsWith("secret."))) return "[REDACTED]";
if (labels.some((l) => l.startsWith("pii."))) {
return "sha256:" + crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 12);
}
return value;
}
export function classifiedLog(eventName, payload) {
const out = { event: eventName };
for (const [key, value] of Object.entries(payload)) {
const labels = labelsByField[key] || ["internal"];
out[key] = redactValue(value, labels);
if (labels.some((l) => l.startsWith("secret.")) && out[key] !== "[REDACTED]") {
throw new Error(`classification policy violation: ${key}`);
}
}
console.log(JSON.stringify(out));
}
This reads labels from schema metadata and applies deterministic redaction before logging. Gotcha: hashing low-entropy values like country codes or booleans is not protection; only hash fields where preserving joinability is useful and dictionary reversal risk is acceptable.
Example 3: SQL export that only selects allowed columns
⚠️ If you replace an existing export query in production, you can break downstream reports or partner feeds immediately. Run the new query side-by-side first and diff row counts and column sets before switching the job.
CREATE VIEW analytics.user_signup_export AS
SELECT
marketing_opt_in,
country,
created_at
FROM app.user_signup;
This is the blunt but reliable version: materialize policy in an export view that excludes labeled pii.* and secret.* columns. Gotcha: if someone later adds email_hash to the table, decide explicitly whether it is still pii in your taxonomy; pseudonymized does not automatically mean unrestricted.
A practical rollout path:
- Add labels to one canonical schema or model package.
- Enforce only in logging first; count violations.
- Add serializer/export enforcement for one outbound path.
- Add CI checks that every new field has a label.
A simple CI check can be as crude as failing on unlabeled schema properties:
jq -e '.properties | to_entries[] | select(.value["x-classification"] == null)' schemas/user-signup.json >/dev/null
status=$?
if [ "$status" -eq 0 ]; then
echo "unlabeled fields found"
exit 1
fi
On failure, the output shape is usually just:
unlabeled fields found
If you want more detail during diagnosis:
jq '.properties | to_entries[] | select(.value["x-classification"] == null) | .key' schemas/user-signup.json
Typical output:
"birth_date"
Further reading
- OpenAPI Specification — Specification Extensions
- JSON Schema — Annotations and Metadata
- Protocol Buffers Language Guide — Custom Options
- NIST Privacy Framework
- OWASP Logging Cheat Sheet
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI