Skip to content

The Masked Public Roster: Ship Names, Not Phone Numbers

Adityo Guni Waluyo

Hiding sensitive columns in the UI is not protection. The real mask lives in the API layer, proven by negative E2E assertions.

TL;DR

The article describes a common flaw where the UI hides sensitive fields but the API still sends full records to the client, violating OWASP API3:2023 and Indonesia's data minimization rules. The fix strips fields server-side via a projection DTO containing only public data. A Playwright test enforces negative assertions, ensuring PII never reaches the DOM.

While reviewing the public roster page for a youth development program on KotaPortal, I opened the browser's network tab and found the old, dangerous pattern: the UI hides sensitive columns from view, but the API response still ships the raw record to the client. It looks safe on screen while everything sensitive has already crossed the network. Security standards even have a name for this failure: OWASP API3:2023 Broken Object Property Level Authorization [1]. APIs tend to return all object properties, and the read variant used to be called Excessive Data Exposure. The impact is direct: private data leaks to anyone who inspects traffic.

The legal boundary here is explicit. Indonesia's Personal Data Protection Law 27/2022, Article 16 paragraph (2) letter a [5], requires collection to be "terbatas dan spesifik, sah secara hukum, dan transparan" — limited and specific, lawful, and transparent. The government's official explanation ties "limited and specific" directly to the declared processing purpose, fixed explicitly at collection time [3]. For a public roster, that purpose is showing a participant's name, program field, and activity location — not distributing personal contacts. One correction I got wrong at first: this principle is not in Article 8. Article 8 grants the data subject the right to end processing and request deletion. Details like that matter when someone audits your claims against the statute.

The mask goes in the API, not the screen

The fix in this commit puts field stripping at the API layer. The public endpoint constructs a projection DTO containing only the name, program field, activity location, and domicile. Birth date, WhatsApp number, and document links never leave the server. The public page renders that DTO verbatim. There is no "don't show this" logic on the client, because there is nothing left to hide.

{
  "full_record": {
    "id": "usr_88392",
    "nama": "Budi Santoso",
    "tanggal_lahir": "1995-08-12",
    "whatsapp": "081234567890",
    "domisili": "Kota Nusantara",
    "bidang_program": "Pelatihan Digital",
    "lokasi_kegiatan": "Gedung Pemuda Lt. 2",
    "tautan_dokumen": "https://drive.example.com/file/xyz123"
  },
  "masked_dto": {
    "nama": "Budi Santoso",
    "bidang_program": "Pelatihan Digital",
    "lokasi_kegiatan": "Gedung Pemuda Lt. 2",
    "domisili": "Kota Nusantara"
  }
}

The admin table still needs full data for legitimate operations, so its path is separated: read-only, locked behind a module-read permission, and scoped to the operator's organizational unit. Not blanket access with a hope that nobody abuses it.

The guarantee is machine-checked, not promised

Masking at the API without a test is just a code-review promise. The E2E spec in this commit enforces the negative side: it does not check that names appear; it checks that forbidden patterns never show up in the rendered DOM. Indonesian mobile number patterns, ISO-formatted dates, and the document-storage domain are all banned. Playwright's expect assertions [2] retry automatically until the condition passes or the timeout hits, so an asynchronously rendered page does not make the body read flaky.

import { test, expect } from "@playwright/test";

test("public roster does not leak PII", async ({ page }) => {
  await page.goto("/program/public-roster");
  const body = await page.locator("body").innerText();

  // No Indonesian mobile number pattern
  expect(body).not.toMatch(/08\d{8,11}/);

  // No ISO-formatted date (YYYY-MM-DD)
  expect(body).not.toMatch(/\d{4}-\d{2}-\d{2}/);

  // No private document-storage link
  expect(body).not.toContain("drive.example.com");
});

Negative assertions catch regressions from any layer. Whenever someone adds a sensitive field back to the DTO, calls an unmasked endpoint, or renders raw data in a new component, this test goes red before the code reaches production. The security guarantee stops being "hopefully nobody forgets" and becomes a fact the pipeline re-verifies on every run.

One floor down: the database view

To enforce the projection even earlier, the database can be the control point. PostgreSQL's CREATE VIEW [4] defines a view that is not physically materialized; the query runs every time the view is referenced. A column-subset view can never go stale the way a cached copy does, because the data is always recomputed from the base table. For row-level filtering needs, PostgreSQL offers the security_barrier option so the security condition is evaluated before other parts of the query. The API DTO remains the public contract, but the view makes sensitive columns harder to carry along by accident.

The pattern is not specific to youth programs. Faculty directories, volunteer lists, member catalogs — they all face the same problem. Put the mask in the API layer, let the public page know only the DTO, and back it with negative assertions that prove PII never reaches the DOM. The law already says collection must be limited to the purpose; code and tests just make that promise hold in every response.

Sources

Related articles