FERPA-Compliant PII Handling for LMS Data Pipelines
Every field that arrives from Canvas, Moodle, or Blackboard carries a compliance obligation before it carries analytical value. The Family Educational Rights and Privacy Act (FERPA) governs how a school handles personally identifiable information (PII) drawn from a student’s education records, and for an EdTech engineering team that obligation is not a legal footnote bolted on at the reporting layer — it is a data-model constraint that shapes the schema from the first ingested field outward. The LMS Data Architecture & Schema Mapping reference establishes where the tokenization boundary sits in the pipeline topology; this page is the compliance engineering that governs what crosses it and how. It treats FERPA as five interlocking mechanisms — field classification, tokenization, salt management, audit logging, and role-based access — that only work as an integrated whole.
The failure of a bolt-on approach is predictable. A team ships an analytics pipeline, tokenizes the obvious student ID, and considers the job done — then a date of birth, a home address, and a disability accommodation flag ride through untouched because nobody wrote down that they were regulated. FERPA compliance is a classification problem first and a cryptography problem second. This page covers the FERPA data model that distinguishes directory information from protected records, a machine-readable field-classification registry that drives every downstream decision, the inputs that arrive as PII from each vendor, the tokenize-or-drop gate and the salt lifecycle behind it, the append-only audit requirement, role-based access at the serving layer, and the failure modes that re-identify learners despite good intentions.
The FERPA Data Model: Directory Information vs Education Records
FERPA draws a bright line that engineers must encode as data, not as tribal knowledge. An education record is any information directly related to a student and maintained by the institution — grades, transcripts, disciplinary records, attendance, and the identifiers that link them. Directory information is a narrow, institution-designated subset (name, enrollment status, dates of attendance, degrees) that a school may disclose without consent, but only when the student has not opted out. Everything else that identifies a learner is PII inside a protected education record and cannot leave the compliance boundary in the clear.
That distinction produces three treatment classes, and every LMS field maps to exactly one of them:
| Category | FERPA status | Examples | Pipeline treatment |
|---|---|---|---|
direct_identifier |
Protected PII | sis_user_id, login_id, email, SSN fragment |
tokenize — replace with an opaque stu_… token |
quasi_identifier |
Protected in combination | date of birth, ZIP, gender, section | tokenize or generalize — high re-identification risk together |
sensitive_attribute |
Protected, non-joining | disability accommodation, free-lunch status, disciplinary flag | drop unless a use case legitimately requires it |
directory |
Disclosable if not opted out | display name, enrollment status, degree | pass with an opt-out check |
educational_record |
Protected but analytical | score, points_possible, attendance_state | pass once keyed to a token, never to raw PII |
The quasi_identifier row is the one teams miss. No single quasi-identifier names a student, but date of birth plus ZIP plus gender re-identifies a large fraction of any population — the classic mosaic effect. A defensible design therefore never lets an analytical dataset carry a raw student ID and the constellation of quasi-identifiers that would let an analyst reverse a token by inference.
A Field-Classification Registry as the Entity Model
The entity that makes this tractable is a field-classification registry: one row per source field, declaring its category and its treatment, checked into version control and reviewed like any other contract. It is the compliance sibling of the versioned schema registry from the architecture reference — the same governance discipline, applied to privacy rather than to types. Its shape is deliberately small:
source_system—canvas|moodle|blackboardsource_path— the field as it arrives (user.login_id,mdl_user.idnumber,userName)canonical_column— the name it maps to after normalizationcategory— one of the five classes abovetreatment—pass|tokenize|drop
Making classification data rather than code means the runtime gate, the audit logger, and the access-control policy all consume one source of truth. When a vendor adds a field in a quarterly release, an unclassified field is treated as drop by default — fail-closed, never fail-open — so a newly-appearing home-phone column cannot silently reach analytics because nobody remembered to handle it. The registry is also the artifact a compliance officer reviews: a human-readable table answering “which student fields does this pipeline touch, and what does it do with each?” without reading a line of Python.
PII Inputs: What Arrives From Each Vendor
The registry is only trustworthy if it enumerates every PII-bearing field each LMS actually returns. The three platforms surface identifiers under different names and in different envelopes, and a classification that only covers Canvas leaves Moodle and Blackboard fields unhandled.
From Canvas, a submissions or users read with include[]=user embeds user.id, user.sis_user_id, user.login_id, and often user.email and user.short_name. The sis_user_id is the institutional key that links to the Student Information System, and it is the single most sensitive join field in the payload. From Moodle, the core_user_get_users web service returns id, idnumber (the SIS key), username, email, firstname, lastname, and — if configured — custom profile fields that can include anything an institution chose to collect. From Blackboard, the /learn/api/public/v1/users endpoint returns id, userName, externalId, contact.email, and a name object. Each of these direct identifiers must resolve to a stable token so that a single learner reconciles across all three systems — the deterministic join that Cross-LMS Student ID Mapping depends on — while the raw value never reaches an analytical table.
The design rule is that tokenization must be deterministic and cross-platform: the same underlying learner, arriving as sis_user_id in Canvas and idnumber in Moodle, must produce the same token, or the canonical dim_learner fragments into three partial profiles. That requirement — one salt, one algorithm, applied identically wherever an identifier enters — is exactly what makes salt management the load-bearing part of the whole design.
The Tokenization Boundary and Salt Lifecycle, In Depth
Tokenization replaces a direct identifier with an opaque, non-reversible token computed as a keyed hash. The keyed part is essential: a bare sha256(raw_id) is trivially reversible for a known population, because an attacker who suspects a student ID format can hash every candidate and match. Keying the hash with a secret salt via HMAC defeats that dictionary attack — without the salt, the token space is computationally unsearchable. The reference tokenizer and its versioned-salt handling are built end to end in Implementing Salted SHA-256 Tokenization for Student IDs; the design constraints that guide it are three.
First, the salt lives in a secrets manager, never in code or in analytical storage. If the salt sits in the same warehouse as the tokens, re-identification is a single join away and the boundary is fictional. The salt belongs in a restricted vault — AWS Secrets Manager, HashiCorp Vault, or an equivalent — that only the ingestion tier can read.
Second, salts are versioned, not replaced. Rotating a salt changes every token, which would shatter longitudinal joins if done naively. The answer is a salt_version carried alongside each token: new data tokenizes under the current version, historical data keeps its original version, and a small versioned-salt map lets the ingestion tier re-tokenize a batch deterministically when a controlled migration is genuinely required. Rotation on a schedule (or on suspected compromise) is a FERPA safeguard, but it must be a versioned, planned migration rather than an accidental key change.
Third, the reversible mapping is a separate, access-controlled vault. Some legitimate use cases — a registrar responding to a records request, an intervention team contacting an at-risk student — need to reverse a token to a real identity. That reversal happens only inside the restricted zone, through a vault the analytical layer cannot reach, and every reversal is itself an audited event.
The Audit-Log Requirement
FERPA requires that an institution be able to answer “who accessed which student’s records, when, and why.” That is not satisfiable by application logs scattered across services; it requires a deliberate, append-only audit log that records every access to protected data as an immutable row. The minimum schema captures the actor (a tokenized principal, not a raw name), the action, the timestamp, the stated purpose, the tokenized subject whose record was touched, and the source job. The full Postgres design — the append-only DDL, tamper-evidence, and retention — is the subject of Designing a FERPA Audit-Log Schema in Postgres.
The critical property is immutability: an audit row must be impossible to update or delete through the application path, so that the log is trustworthy evidence during a breach review. Writing an audit row around every gate decision — tokenize, drop, pass, and every vault reversal — is what closes the loop between the classification registry and the compliance officer’s question.
Role-Based Access at the Serving Layer
Even fully tokenized data must not be uniformly visible. FERPA’s disclosure limits mean an analyst should see de-identified aggregates but never a reversible identifier, an instructor should see only their own sections, and only registrar tooling should touch the vault. That is role-based access control, and in a pipeline it operates on two axes at once: column-level (drop tokenized-PII and quasi-identifier columns for an analyst) and row-level (an instructor’s query is filtered to sections they teach). The runnable policy, guard decorator, and column/row filters are built in Role-Based Access Control for LMS Data in Python. Access control is the last mechanism, but it fails without the first four: you cannot filter a column you never classified, and you cannot prove an access decision you never audited.
Reference Implementation: A Classification + Tokenization Gate
The gate below is the runtime heart of the design. It loads the field-classification registry, and for each incoming field routes on its declared treatment: tokenize runs a keyed HMAC, drop discards the value entirely, and pass forwards it. Unknown fields fail closed to drop. Every decision is emitted to an audit hook. It uses dataclasses and Python 3.10+ syntax.
import hashlib
import hmac
from dataclasses import dataclass, field
from enum import Enum
class Treatment(str, Enum):
PASS = "pass"
TOKENIZE = "tokenize"
DROP = "drop"
@dataclass(frozen=True)
class FieldRule:
source_path: str # e.g. "user.sis_user_id"
canonical_column: str # e.g. "learner_sis_key"
category: str # direct_identifier | quasi_identifier | ...
treatment: Treatment
@dataclass
class ClassificationGate:
"""Routes each incoming field by its registered FERPA treatment."""
registry: dict[str, FieldRule] # keyed by source_path
salt: bytes # loaded from a secrets manager
salt_version: str = "v1"
audit: list[dict] = field(default_factory=list)
def _tokenize(self, raw: str) -> str:
# Keyed HMAC-SHA256 — reversal needs the salt, held only in the vault.
digest = hmac.new(self.salt, raw.encode("utf-8"), hashlib.sha256).hexdigest()
return f"stu_{self.salt_version}_{digest[:24]}"
def apply(self, source_path: str, value: object,
actor: str, purpose: str) -> tuple[str, object] | None:
# Fail closed: an unclassified field is dropped, never passed.
rule = self.registry.get(source_path)
treatment = rule.treatment if rule else Treatment.DROP
column = rule.canonical_column if rule else source_path
self.audit.append({"actor": actor, "purpose": purpose,
"source_path": source_path, "treatment": treatment.value})
if treatment is Treatment.DROP:
return None
if treatment is Treatment.TOKENIZE:
return (column, self._tokenize(str(value)))
return (column, value) # PASS
if __name__ == "__main__":
registry = {
"user.sis_user_id": FieldRule("user.sis_user_id", "learner_sis_key",
"direct_identifier", Treatment.TOKENIZE),
"user.dob": FieldRule("user.dob", "dob", "quasi_identifier", Treatment.TOKENIZE),
"user.accommodations": FieldRule("user.accommodations", "accommodations",
"sensitive_attribute", Treatment.DROP),
"submission.score": FieldRule("submission.score", "score",
"educational_record", Treatment.PASS),
}
gate = ClassificationGate(registry=registry, salt=b"rotate-me-from-secrets-manager")
incoming = {"user.sis_user_id": "SIS-90233", "user.accommodations": "extended_time",
"submission.score": 87.5, "user.home_phone": "555-0100"} # last is unclassified
clean = {}
for path, val in incoming.items():
out = gate.apply(path, val, actor="etl_job_load", purpose="nightly_grade_sync")
if out is not None:
clean[out[0]] = out[1]
print(clean) # accommodations dropped, home_phone dropped, sis_user_id tokenized
print(gate.audit) # one audit row per field decision
The gate’s value is that classification, tokenization, and auditing are a single indivisible operation: a field cannot be tokenized without being classified, and cannot be processed without being audited. The stu_v1_… token embeds its salt version so a later rotation stays deterministic, and the fail-closed default means a new vendor field never leaks by omission.
Failure Modes and Edge Cases
FERPA breaches in data pipelines rarely look like a hack; they look like a small classification gap that compounds. The recurring ones are worth designing against explicitly.
Salt leakage. The salt ends up in a config file, a notebook, an environment variable dumped into a log, or the same database as the tokens. Any of these makes every token reversible by brute force. Fix: the salt lives only in a secrets manager, is never logged, and rotation is versioned so a suspected leak triggers a controlled re-tokenization rather than a silent break.
Re-identification via quasi-identifiers. The direct ID is tokenized, but date of birth, ZIP, section, and gender ride through untouched, and their combination re-identifies a large share of students despite the token. Fix: classify quasi-identifiers as regulated, tokenize or generalize them, and never let a dataset carry both a token and its re-identifying constellation.
Over-collection. The pipeline hoards every field “in case it’s useful later,” accumulating accommodations, free-lunch status, and disciplinary flags that no analytical use case needs. FERPA’s data-minimization principle makes storage itself the liability. Fix: default sensitive_attribute fields to drop, and require a documented, audited justification to reclassify one to pass.
Audit gaps. Access happens through a path that bypasses the audit logger — a direct database query, an ad-hoc export, a debugging session — so the institution cannot answer “who saw this record.” Fix: route all protected-data access through a guarded layer that writes an append-only audit row, and revoke direct table grants that would let a principal sidestep it.
Inconsistent tokens across platforms. Canvas and Moodle tokenize the same learner under different salts or algorithms, so the canonical profile fragments and the mosaic risk rises because partial records must be re-joined by quasi-identifiers. Fix: one salt, one algorithm, one salt_version map applied identically at every ingress point.
The unifying rule is classify before you touch, tokenize before you join, audit before you serve. Because classification is data and the gate is fail-closed, a new field is safe by default and a compliance review is a table read rather than a code audit — which is what lets an institution build learning analytics that respect the student records they depend on.
Related
- Implementing Salted SHA-256 Tokenization for Student IDs — the HMAC tokenizer with versioned salts, batch DataFrame tokenization, and a separate reversible vault.
- Designing a FERPA Audit-Log Schema in Postgres — the append-only audit table DDL, immutable-write helper, retention, and tamper-evidence.
- Role-Based Access Control for LMS Data in Python — a role-to-permission policy with column and row filters that drop PII per role.
- Cross-LMS Student ID Mapping — the deterministic join the tokenizer feeds, resolving one learner across Canvas, Moodle, and Blackboard.
- LMS Data Architecture & Schema Mapping — the pipeline topology that positions the tokenization boundary these compliance guides deepen.