Apache HBase Integration Guide
This guide covers how to connect an Apache HBase cluster to BurnLedger, configure row-key query templates, supply a read-only principal, and understand exactly what gets hashed.
BurnLedger talks to HBase through the HBase REST gateway (Stargate) — not Thrift and not the native RPC protocol. The gateway must be running and reachable from BurnLedger's egress addresses.
Connection Configuration
Configuration fields:
| Field | Required | Description |
|---|---|---|
host |
Yes | Hostname or address of the HBase REST gateway. Not a RegionServer, not ZooKeeper. |
port |
No | REST gateway port (default: 8080). |
tls |
No | Transport encryption. Defaults to true — omit it and the connector uses https://. "tls": false selects a plaintext gateway, which classifies plaintext and a production deployment then refuses to build at all; see Transport Security (TLS). |
username |
No | Reserved for gateway authentication. See A note on username / password before relying on it. |
password |
No | Reserved for gateway authentication. See the same note. |
read_only |
Yes | Must be true. Asserts that the principal the gateway uses cannot write. Construction is refused without it — see Read-Only Principal Setup. |
There is no dsn, no table and no namespace field: the table is part of the query template, not the connection.
TypeScript
const system = await bl.registerSystem({
name: "users-hbase",
connectorType: "hbase",
connectionConfig: {
host: "hbase-rest.example.com",
port: 8080,
tls: true,
read_only: true,
},
subjectQuery: "users/{identifier}",
});
Python
system = bl.register_system(
name="users-hbase",
connector_type="hbase",
connection_config={
"host": "hbase-rest.example.com",
"port": 8080,
"tls": True,
"read_only": True,
},
subject_query="users/{identifier}",
)
Connection requirements:
| Requirement | Details |
|---|---|
| REST gateway | Required. Start it with hbase rest start -p 8080 (or run it as a service). The native RPC port (16020) and the Thrift port (9090) will not work. |
| Connectivity probe | On registration BurnLedger issues GET /status/cluster against the gateway. Anything other than 200 OK fails registration with CONNECTION_FAILED. |
| HTTP methods | The gateway must accept PUT /<table>/scanner, GET on the returned scanner URL, and DELETE on it. Scanner creation is a read operation server-side, but it uses HTTP PUT — proxies or WAFs that strip non-GET methods will break scanning. |
| Network access | BurnLedger connects from a fixed egress address. Allowlist it on the gateway host or its load balancer. |
| Permissions | Read-only, asserted with read_only: true. See Read-Only Principal Setup. |
| Timeout | The HTTP client uses a 30-second timeout per request; each attestation query is additionally bounded by the system's query timeout. |
A note on username / password
username and password are accepted by the connector config, but the current HBase connector does not attach them to its HTTP requests — no Authorization header is sent to the gateway. In practice this means:
- The gateway must accept BurnLedger's requests without HTTP-level credentials (typical for a gateway restricted at the network layer).
- The effective HBase principal is whichever principal the REST gateway itself runs as (or impersonates by default), not a principal you pass here.
That makes the gateway principal the thing that must be read-only. Do not rely on these two fields to scope access; scope it on the gateway.
Transport Security (TLS)
TLS is on by default. Omitting tls uses https:// for the gateway base URL, which requires the gateway to be configured for SSL (hbase.rest.ssl.enabled=true, with hbase.rest.ssl.keystore.store / .password set), or fronted by a TLS-terminating proxy.
Changed:
tlspreviously defaulted tofalse, so a config that never mentioned transport sent row keys and cell values in cleartext. Existing configs that relied on the old default now fail withCONNECTION_FAILEDagainst a plaintext gateway — enable SSL on the gateway, or front it with a TLS-terminating proxy."tls": falserestores the old wire behaviour but is not a fix: see below.
The gateway's certificate is validated against the standard public trust store. There is no option to disable verification and no field for a custom CA bundle, so a self-signed or private-CA certificate will fail with CONNECTION_FAILED. If your gateway uses a private CA, terminate TLS on a proxy with a publicly trusted certificate.
"tls": false classifies plaintext, which is below the transport floor, so a
production BurnLedger refuses to build the connector at all — the system registers,
its health check reports connection failed, and every attestation against it
fails. It is usable only on a deployment running with
DP_ALLOW_UNVERIFIED_TRANSPORT=true, which is development and test only: traffic
to the gateway, including row keys and cell values and the record count that gets
signed, is unencrypted, and an on-path attacker chooses that count. See
connector transport security.
Query Template Format
For HBase systems the query template is a row-key prefix scan, not a SQL query. It uses {identifier} as the placeholder for the data subject identifier.
Format:
<table>/<row-key-prefix>
Rules:
- The template must contain
{identifier}. A template without it is rejected at attestation time withINVALID_QUERY_TEMPLATE. This is deliberate: a template with no placeholder would expand to a fixed prefix, match nothing, and produce a signed verification record asserting "0 records" for a subject whose data is still there.{identifier}is the only placeholder this connector substitutes —?,$1,:idand{{subject}}are all plain text to it. - Everything before the first
/is the table name; everything after it is the row-key prefix. Namespaced tables use HBase's ownnamespace:tablesyntax (a colon), soanalytics:users/{identifier}is a valid template. - Include the
/. A template with no slash is read as a bare table name with an empty prefix, which scans the entire table — almost certainly not what you want, and it will tripRECORD_LIMIT_EXCEEDEDon any real table. - The scan is a prefix match anchored at the start of the row key: BurnLedger scans from
startRow = <expanded prefix>toendRow = <expanded prefix with its final byte incremented>. Row keys must therefore begin with the expanded prefix. There is no substring, regex, column-value or filter support. - Because your row keys must start with the subject identifier, this connector fits designs that key by subject (
<user_id>#<event_ts>) and does not fit designs that salt or hash-prefix row keys for region spreading. See Row-key design below. - Avoid a prefix whose last byte is
0xFF; incrementing it wraps and the scan range becomes invalid. Keep a printable separator (#,|,-) at the end of the prefix and this never arises.
Examples:
# Row keys are exactly the subject identifier, or start with it
users/{identifier}
# Row keys are "<user_id>#<event_timestamp>" -- separator included in the prefix
user_events/{identifier}#
# Table in a namespace
analytics:page_views/{identifier}|
# Composite key with a fixed leading tenant, subject second: NOT supported
# (the prefix must start at byte 0 of the row key)
# events/tenant42#{identifier} <- only works if row keys literally start with "tenant42#"
Row-key design
The prefix scan is the only access path, so the subject identifier has to be at the head of the row key. If your table salts row keys (e.g. md5(user_id)[0:2] + user_id) or leads with a timestamp bucket, a single template cannot reach the subject's rows. Options:
- Register one BurnLedger system per salt bucket, each with its own template, and attest against all of them.
- Maintain a secondary index table keyed by subject and register that table instead — but note that this proves deletion in the index, not in the base table.
What gets hashed
Each row returned by the scan is hashed as a canonical field set:
_rowkey— the raw row key bytes.- One field per cell, named
family:qualifier, holding the raw cell value bytes.
Cell timestamps are not part of the hash, so a rewrite that changes only the version timestamp does not change the record hash. The scanner returns the latest version of each cell.
When a zero is refused
BurnLedger will not certify a zero it cannot account for. A prefix scan that returns no rows is the whole product, and there are three ways HBase produces that answer without the subject's rows being gone. So when a subject query returns nothing, BurnLedger asks two further read-only questions before it reports a zero.
1. Is the table still one this connection can read? BurnLedger asks the
gateway for its table list (GET /) and looks for the table your template
names. HBase's AccessController filters that list to the tables the principal
holds a permission on, so one call answers both halves of the question.
- The table is listed → carry on to question 2.
- The table is not listed → refused,
UNDETERMINED. Either the table was renamed or dropped, or the principal has no grant on it.
This one matters more than it looks. The REST gateway reports a scan it was
not allowed to run as a scan that finished. ScannerResultGenerator catches
the AccessDeniedException, logs it server-side, and the scanner answers 204
No Content — the same status an exhausted scan gets. Without the table-list
check, a template pointed at a table your principal cannot read produces a clean
zero from every scan BurnLedger can make, over rows that are sitting right
there. Check your gateway's log for AccessDeniedException if you hit this
refusal and believe the grant is correct.
2. Does the row-key prefix still address rows? The literal text your
template puts before {identifier} — u# in users/u#{identifier} — is a
naming convention your application owns and the template merely copies. Renaming
it leaves the template scanning a range nothing writes to.
- Rows exist under the prefix (other subjects') → the zero is a real absence. Certified.
- The table holds no rows at all → there is nowhere else in it the subject could be. Certified.
- The table holds rows, but none under the prefix → refused,
UNDETERMINED, naming the prefix. Either the convention moved, or it is real but now entirely empty; point-in-time those are the same observation and BurnLedger refuses both.
A template whose row key starts with {identifier} has no convention above
the subject, so question 2 does not apply to it.
Neither question is asked when the scan finds rows — a non-zero count fails the
deletion claim on its own. Both run under the same table-scoped READ grant the
scan uses; neither needs a wider permission.
What is not checked: KEEP_DELETED_CELLS
An HBase Delete writes a tombstone. On a column family configured
KEEP_DELETED_CELLS=TRUE, that tombstone hides the cell from ordinary reads but
does not make it unreadable: a Get whose time range ends before the delete
marker still returns the value, with no raw scan and no operator involved. On
such a family, a deleted subject's data is one ordinary read away from being
back.
BurnLedger does not check this, and cannot. The only endpoint that reports
column-family attributes is GET /<table>/schema, which HBase serves through
getTableDescriptor — and its AccessController check requires ADMIN or
CREATE on the table, not READ. A correctly least-privileged BurnLedger
principal gets 403 AccessDeniedException … action=CREATE. Granting C or A
to make the check possible would break the read-only assertion the connector is
built on, which is a worse trade.
This is a template- and schema-review obligation on your side. Run
describe '<table>' in the HBase shell with an administrative account and
confirm KEEP_DELETED_CELLS => 'FALSE' on every family before relying on an
HBase deletion certificate. VERSIONS and MIN_VERSIONS do not need
checking: measured against HBase 2.1.3, neither VERSIONS=5 nor MIN_VERSIONS=2
with a TTL keeps a deleted cell readable — MIN_VERSIONS protects cells from
TTL expiry, not from a Delete.
Read-Only Principal Setup
BurnLedger cannot verify read-only access for HBase, and it says so rather than guessing.
HBase enforces authorization in the server-side AccessController coprocessor. Those ACLs are not exposed over the REST gateway's wire API, so there is no read-only call BurnLedger can make to learn whether a principal may write. The only way to prove write access over this API is to perform a write, which BurnLedger must never do against your data.
So the connector falls back to an explicit operator assertion:
- Set
"read_only": truein the connection config to assert, on your authority, that the principal cannot write. - Omit it (or set it to
false) and registration is refused withCONNECTION_FAILEDand the reasonHBase exposes no permission introspection over its wire API. It fails closed; it never assumes read-only.
What BurnLedger actually checks for HBase:
| Check | Performed? |
|---|---|
Gateway is reachable and healthy (GET /status/cluster returns 200) |
Yes |
| Principal's ACLs inspected for write grants | No — not possible over the REST API |
| Any write, probe write, or dry-run write to your cluster | Never |
read_only: true present in config |
Yes — required, refuses construction otherwise |
Because the assertion is yours, the credential behind it must genuinely be read-only. As noted above, the connector sends no credentials of its own, so the principal to lock down is the one the REST gateway runs as or impersonates.
Create a read-only principal
With the AccessController coprocessor enabled (hbase.security.authorization=true and AccessController in hbase.coprocessor.region.classes, .master.classes and .regionserver.classes), grant only R (READ) in the HBase shell:
# Grant READ on a single table
hbase> grant 'burnledger_ro', 'R', 'users'
# Grant READ on every table in a namespace
hbase> grant 'burnledger_ro', 'R', '@analytics'
# Grant READ on one column family only -- NOT sufficient for BurnLedger, see below
hbase> grant 'burnledger_ro', 'R', 'users', 'profile'
Grant READ at table or namespace scope, not column-family scope. BurnLedger's scanner sends only batch, startRow and endRow — it never sets a column filter — so every scan reads all column families of the table. A principal granted READ on a single family will have those scans rejected (AccessDeniedException, surfaced as CONNECTION_FAILED). This also means every family's cell values end up in the record hash; if a family must not be read, put it in a separate table.
Grant R and nothing else. W (write), C (create), A (admin) and X (exec) must all be absent — a single W anywhere in the chain makes the assertion false.
Verify the permissions
# List the effective grants -- expect READ only
hbase> user_permission 'users'
hbase> user_permission '@analytics'
# As burnledger_ro, this must succeed
hbase> scan 'users', {LIMIT => 1}
# As burnledger_ro, these must all fail with AccessDeniedException
hbase> put 'users', 'test-row', 'profile:email', 'test@test.com'
hbase> delete 'users', 'test-row', 'profile:email'
hbase> disable 'users'
Also confirm the scan path BurnLedger uses actually works, straight against the gateway:
# Health probe -- must return 200
curl -i -H "Accept: application/json" https://hbase-rest.example.com:8080/status/cluster
# Create a scanner (note: HTTP PUT) -- must return 201 with a Location header
curl -i -X PUT -H "Content-Type: application/json" \
-d '{"batch": 10}' \
https://hbase-rest.example.com:8080/users/scanner
Revoke write privileges if they exist
# Remove all grants for the principal on a table, then re-grant READ only
hbase> revoke 'burnledger_ro', 'users'
hbase> grant 'burnledger_ro', 'R', 'users'
# Same at namespace scope
hbase> revoke 'burnledger_ro', '@analytics'
hbase> grant 'burnledger_ro', 'R', '@analytics'
Passwords in URIs. BurnLedger's HBase config takes
hostandportas separate fields, so no connection URI is involved here. If you build a gateway URL by hand anywhere else (acurlcheck, a proxy config), remember a URI must percent-encode special characters in credentials —@→%40,:→%3A,/→%2F,?→%3F,#→%23— or the host is parsed wrongly. Using a password limited to letters, digits,-and_sidesteps the problem entirely.
Hash Scope Options
The hashScope parameter controls what BurnLedger hashes for each record a system returns.
HBase does not currently implement the distinction. The connector hashes the row key plus every cell value returned for the row, whatever
hashScopeis set to — there is no existence-only path in the HBase code. SetproofMode: "merkle"explicitly on HBase systems so the scope recorded on the attestation matches what is actually hashed. If you omithashScope, the API defaults it toexistence, which would label the system inaccurately even though the hashing does not change.
full (the effective behavior)
Hashes the row key plus every cell value returned for that row. Any change to any cell value produces a different hash, giving you the strongest proof that data has or has not been modified.
const system = await bl.registerSystem({
// ...
});
Use when: You need to prove the exact data that existed, or prove that data was deleted rather than merely blanked out (nulling PII cells is not the same as deletion under some interpretations of GDPR).
existence
On the object-store connectors this chooses between hashing content and hashing only existence. HBase ignores hash_scope entirely — it hashes the row key and every cell value regardless, so existence does not avoid hashing sensitive content and does not speed up wide rows.
Registration refuses hash_scope: "full" here, since the field is inert and would only put an unbacked claim on the verification record. Use proof_mode: "merkle" on the attestation for content-level proof.
Behavior
| HBase (either scope) | |
|---|---|
| Detects row deletion | Yes |
| Detects row insertion | Yes |
| Detects cell value changes | Yes |
| Detects cell timestamp-only rewrites | No |
| Hash includes cell content (PII) | Yes |
| Recommended for GDPR Art. 17 | Yes |
End-to-End Example
This example walks through the full process: preparing the read-only principal, registering the system, attesting before deletion, deleting, verifying, and generating a verification record.
Assume a table users whose row keys are <user_email>#<record_type>, e.g. jane.doe@example.com#profile.
1. Cluster setup (run once)
# In the HBase shell, as an admin
hbase> grant 'burnledger_ro', 'R', 'users'
hbase> user_permission 'users'
# Confirm the REST gateway is up (on the gateway host)
$ hbase rest start -p 8080
2. Application code
TypeScript
import { BurnLedger } from "burnledger";
import fs from "node:fs/promises";
const bl = new BurnLedger({
apiKey: process.env.BURNLEDGER_API_KEY,
});
// Register the system (run once, store the system ID)
const system = await bl.registerSystem({
name: "myapp-users-hbase",
connectorType: "hbase",
connectionConfig: {
host: "hbase-rest.example.com",
port: 8080,
tls: true,
read_only: true,
},
// Row keys are "<email>#<record_type>", so the prefix ends at the separator
subjectQuery: "users/{identifier}#",
});
// --- When a deletion request comes in ---
const subjectEmail = "jane.doe@example.com";
// Step 1: Attest that the data currently exists
const attestation = await bl.attest(subjectEmail, {
systemIds: [system.id],
proofMode: "merkle",
});
for (const s of attestation.systems) {
console.log(`${s.systemName}: ${s.recordCount} rows found`);
}
// Step 2: Delete the rows in your application
// (e.g. a scan for the same prefix followed by Delete mutations, from a
// principal that *does* have write access -- not this one)
// Step 3: Verify the deletion
// The certificate is ISSUED BY verify() once the data is gone -- there is
// no separate "create certificate" step.
const result = await bl.verify(attestation.id, subjectEmail, { timeout: 60 });
for (const s of result.systems) {
console.log(`${s.systemName}: now ${s.recordCount} rows`);
}
if (result.certificate) {
await bl.savePdf(result.certificate.id, `erasure-cert-${subjectEmail}.pdf`);
console.log(`Certificate issued: ${result.certificate.id}`);
console.log(`Transparency status: ${result.certificate.transparencyStatus}`);
} else {
console.log(`Not certified: status ${result.status}`);
}
Python
import os
from burnledger import BurnLedger
bl = BurnLedger(api_key=os.environ["BURNLEDGER_API_KEY"])
# Register the system (run once, store the system ID)
system = bl.register_system(
name="myapp-users-hbase",
connector_type="hbase",
connection_config={
"host": "hbase-rest.example.com",
"port": 8080,
"tls": True,
"read_only": True,
},
# Row keys are "<email>#<record_type>", so the prefix ends at the separator
subject_query="users/{identifier}#",
)
# --- When a deletion request comes in ---
subject_email = "jane.doe@example.com"
# Step 1: Attest that the data currently exists
attestation = bl.attest(
subject_email,
system_ids=[system.id],
proof_mode="merkle",
)
for s in attestation.systems:
print(f"{s.system_name}: {s.record_count} rows found")
# Step 2: Delete the rows in your application
# (scan the same prefix and issue Delete mutations from a write-capable
# principal -- never the one registered here)
# Step 3: Verify the deletion
# The certificate is ISSUED BY verify() once the data is gone -- there is
# no separate "create certificate" step.
result = bl.verify(attestation.id, subject_email, timeout=60)
for s in result.systems:
print(f"{s.system_name}: now {s.record_count} rows")
if result.certificate:
bl.save_pdf(result.certificate.id, f"erasure-cert-{subject_email}.pdf")
print(f"Certificate issued: {result.certificate.id}")
print(f"Transparency status: {result.certificate.transparency_status.value}")
else:
print(f"Not certified: status {result.status.value}")
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
CONNECTION_FAILED with "HBase ping failed" |
The REST gateway is not running, not reachable, or host/port point at the native RPC (16020) or Thrift (9090) port |
Start the gateway (hbase rest start -p 8080), point host/port at it, and allowlist BurnLedger's egress IPs. |
CONNECTION_FAILED with "HBase ping returned status 401/403" |
The gateway requires HTTP authentication | The connector sends no Authorization header. Restrict the gateway at the network layer instead, or front it with a proxy that authenticates on BurnLedger's behalf. |
CONNECTION_FAILED with a TLS/certificate error |
tls: true against a self-signed or private-CA certificate |
Terminate TLS on a proxy with a publicly trusted certificate. There is no custom-CA or skip-verify option. |
CONNECTION_FAILED on attestation: "connector hbase: transport security plaintext is below the required minimum verified". A health check reports only connection failed — read transport_security on the system |
"tls": false |
Enable SSL on the REST gateway (or front it with a TLS-terminating proxy) and omit tls. |
CONNECTION_FAILED with "cannot verify the credential is read-only" |
read_only missing or false |
Confirm the gateway principal has R only, then set "read_only": true. HBase permissions cannot be introspected, so the assertion is mandatory. |
WRITE_ACCESS_DETECTED |
Not produced by the HBase connector. It has no way to detect write access (no permission introspection over the REST API), so it never reaches this verdict — the read_only assertion above is the only read-only gate |
If you see this code, it came from another system in the same attestation, not from HBase. |
INVALID_QUERY_TEMPLATE |
The template has no {identifier} placeholder |
Rewrite as <table>/<prefix>{identifier}.... No other placeholder syntax is substituted — a template without {identifier} would certify an empty result. |
CONNECTION_FAILED with "create scanner returned status 404" |
The table in the template does not exist, or the namespace prefix is wrong | Check the part before the first /; use namespace:table (colon) for namespaced tables. |
CONNECTION_FAILED with "create scanner returned status 405" |
A proxy or WAF is blocking the PUT /<table>/scanner request |
Allow PUT and DELETE through to the gateway; scanner lifecycle depends on them. |
RECORD_LIMIT_EXCEEDED |
The prefix matched more rows than the system's max_records |
Tighten the prefix (include the separator so it does not match neighbouring keys), or raise max_records for the system. A missing separator often turns {identifier} into an unintended wide prefix. |
| 0 rows found for a subject you expect to have data | Row keys do not begin with the expanded prefix (salted or timestamp-led keys), or the separator is missing from the template | Verify with a manual scan against the gateway; see Row-key design. |
QUERY_HASH_MISMATCH during verification |
Data changed between attestation and verification | Expected if you deleted or modified rows. Review the changes array in the verification result. |