HBase can attach a permission list to an individual cell: this value at this row, column and timestamp can be read by alice and the legal group, written by alice only. The feature is called cell-level ACLs, it is built on cell tags in HFile v3, and it is enforced by the same AccessController coprocessor that handles table grants. On paper it looks like row-level security for a key-value store. In practice it behaves differently from what most people expect, starting with the fact that, under the default configuration, it has no effect at all.
This article explains how the AccessController evaluates cell ACLs, read from the source, then shows how to write and change them, what a write needs before a cell grant applies, what the checks cost, and when to use visibility labels or table design instead. It ends with a worked example and a checklist.
Two neighbouring topics are covered elsewhere: tag storage, visibility labels and how labels combine with ACLs in HBase cell tags and visibility labels, and authentication, wire encryption and Ranger in HBase security with Kerberos and Ranger.
Where cell permissions sit
HBase permissions are Read, Write, Execute, Create and Admin, granted to users or groups (written with an @ prefix) at global, namespace, table, column family, qualifier or cell scope. The first five scopes live in the hbase:acl table and are cached on every server. Cell permissions are different in kind: they are stored inside the data, as a tag of a reserved type on each cell, and they apply to that exact coordinate, row, column and timestamp.
Two consequences follow. A cell ACL belongs to one version of one cell; it is written with the data and disappears when that version is deleted or compacted away. And changing a cell's ACL means writing the cell again. The reference guide says so directly: to change an ACL, write an updated cell with the new ACL to the precise coordinates of the original, and to update every visible version, write every visible version.
The early_out switch
Everything depends on one setting, hbase.security.access.early_out, whose default has been true since 0.98.6. Reading AccessController makes its effect precise.
- If the user has a grant at table, family or qualifier scope that covers the request, the request is allowed and no cell tag is consulted, whatever the setting. A cell ACL can never take away access granted above it.
- If the user has no such grant and early_out is true, reads that touch no granted qualifier and all writes fail with
AccessDeniedException. The cell tags are not read. - Only when early_out is false does the AccessController fall through to cell ACLs.
So with defaults, cell ACLs are stored but inert. To use them you set early_out to false. The AccessController reads the value from its configuration merged with the table descriptor's values, so in the current source it can be set on a single table rather than cluster-wide. That behaviour is visible in the code rather than promised in the documentation, so verify it on your version before depending on it.
Setting it to false changes behaviour for every user without a coarse grant, not only for tables that use cell ACLs. Reads stop throwing and return filtered results instead, which applications may read as missing data rather than missing permission.
How a read is evaluated
With early_out false and no coarse grant, the AccessController wraps the Get or Scan in an AccessControlFilter using its cell strategy. For each cell, the filter includes it if the user has a table or family grant for that column, or if the cell's ACL tag grants READ to the user or one of the user's groups. Everything else is skipped silently. Any filter the client supplied is combined with it so both must pass.
Two practical effects matter. The cost is paid on the server: every candidate cell is read from the block cache or disk and its tag is parsed before being dropped, so a scan over a million cells that returns ten still reads a million. And there is no error to log when access is missing. If a user complains about empty results, the first question is whether a cell ACL was ever written for them.
How a write is evaluated
Writes are where cell ACLs surprise people most. When a Put, Delete, Increment, Append or check-and-mutate arrives from a user without a coarse WRITE grant, the AccessController marks it and, in preBatchMutate, runs a covering-permission check. It reads the existing cells the mutation would cover: for a Put, the latest version of each column it writes; for a Delete that removes a whole column or family, every version. Each covered cell must grant the action to the user, and at least one covered cell must exist and grant it.
That last rule has a direct consequence: a cell ACL can let a user overwrite or delete an existing cell, but it can never let them create a new one. Writing a column that has no cell yet covers nothing, so the check finds no grant and the write is denied. If users need to add new data, they need a family or table WRITE grant, and then cell ACLs no longer restrict their writes at all.
A second consequence concerns the new version. The ACL in a mutation applies to that mutation's cells only; it does not change earlier versions, and an earlier version's ACL is not copied forward. If bob overwrites alice's document with a Put that carries no ACL, the new version has no ACL tag, and users who relied on cell grants lose access to the new value. Depending on how many versions the family keeps, they may see the older version instead. Appends and Increments are the exception: they keep the existing ACL unless the operation carries its own.
Writing and changing ACLs
In Java, attach an ACL to any mutation with setACL, either for one principal or with a map. The ACL is sent as an operation attribute; the server converts it into a tag on each cell. Clients cannot write the reserved tag type directly: mutations from non-superusers carrying it are rejected.
import java.util.HashMap;
import java.util.Map;
import org.apache.hadoop.hbase.client.*;
import org.apache.hadoop.hbase.security.access.Permission;
import org.apache.hadoop.hbase.util.Bytes;
static final byte[] D = Bytes.toBytes("d");
void saveDocument(Table docs, String docId, String owner,
Map<String, Permission.Action[]> shares, byte[] body) throws Exception {
Map<String, Permission> acl = new HashMap<>();
acl.put(owner, new Permission(Permission.Action.READ, Permission.Action.WRITE));
for (Map.Entry<String, Permission.Action[]> s : shares.entrySet()) {
acl.put(s.getKey(), new Permission(s.getValue())); // "@legal" for a group
}
Put put = new Put(Bytes.toBytes(docId));
put.addColumn(D, Bytes.toBytes("body"), body);
put.addColumn(D, Bytes.toBytes("title"), Bytes.toBytes(docId));
put.setACL(acl); // applies to every cell in this Put
docs.put(put);
}Because every write must carry the full ACL, keep the current sharing list in one place the writer can read, for example a small meta:acl column the service maintains, and route all writes through one function like the one above. Never let a code path write the data family without attaching the ACL.
The HBase shell also accepts a cell grant: grant 'docs', { 'bob' => 'R' }, { COLUMNS => 'd:body', FILTER => "PrefixFilter('doc42')" }. It scans the matching cells and writes each one back with exactly that hash as its ACL, replacing the old one, so this example would drop alice's RW; list the owner too. The guide describes shell support for cell labels and permissions as a testing aid rather than a production tool, because it only touches cells that exist when you run it.
Worked example: shared documents
A document service stores one row per document in table docs, family d with VERSIONS => 1. The service account docsvc has table RW and performs all writes. End users read directly through a reporting tool with their own Kerberos identity and have no table grant. The table runs with early_out false.
alice creates doc42 and shares it with bob for reading. The service writes the row with the ACL {alice: RW, bob: R}. bob's Get returns body and title; carol's Get returns an empty result rather than an error. Later alice revokes bob. The service rewrites both cells with {alice: RW}; with one version kept, the old cells, and the old ACL, are gone after the write, and bob's next read is empty.
Now suppose a junior developer adds a bulk job that fixes typos in titles and forgets setACL. The title cells it writes have no ACL, so alice and bob lose the title while still seeing the body. The fix is the single write path above plus a nightly check: scan as a test user who should see every document and compare counts with the service's own view.
What the checks cost
Cell ACLs add three costs. Storage: every cell carries a serialised permission list, which for short values can be larger than the value itself. Reads by users without coarse grants pay for a filter that parses tags on every candidate cell. Writes by users without coarse grants pay for a server-side read of the covered cells before the write, and the source itself calls that check expensive; whole-column and whole-family deletes read every version. Users with table grants pay none of the read or write checks.
The pattern that keeps cost low is the one in the example: services with coarse grants do the writing, and cell ACLs only filter reads by end users. Key design matters too; a row key that puts one tenant's or one user's rows together lets you scan a narrow range instead of filtering the whole table, as described in HBase schema design.
Cell ACLs or something else
| Need | Better tool |
|---|---|
| Teams see different tables or families | Table or family grants, no cell features |
| Data classified by sensitivity, users hold clearances | Visibility labels |
| A few exceptional per-record grants to named people | Cell ACLs with early_out false |
| Tenant isolation at scale | Row key prefix per tenant plus a service that enforces it, or one table per tenant |
Visibility labels change access for every cell at once with one set_auths call; cell ACLs need every affected cell rewritten. That alone makes labels the better fit for anything driven by user role. Cell ACLs earn their place when grants are genuinely per record and change rarely.
Failure modes
- Inert ACLs. early_out is still true, so tags are written and ignored. Check the setting and test with a user without coarse grants.
- Coarse grants win. A group grant at table level silently overrides every cell ACL for its members.
- Superusers and service accounts. Superusers bypass all checks; keep ETL, backup and gateway accounts out of the superuser list.
- Unprotected new versions. A write without setACL produces a cell with no ACL.
- Users cannot create cells. A cell grant never authorises a write to a column with no existing cell.
- Tags lost in transit. Replication, Export and Import must use a tags-aware codec or the copy arrives without ACLs; see HBase replication.
- A different authorizer. If the AccessController is replaced by another authorization plugin, check whether that plugin evaluates cell ACL tags before relying on them.
- HFile v2. Cell features need HFile v3, the default since HBase 1.0; on older formats an ACL write fails with a not-persisted error.
Trade-offs
Cell ACLs give per-record access control inside HBase without a separate policy service, and the policy lives and is versioned with the data. The price is that policy changes become data rewrites, coarser grants always override, missing access looks like missing data, and the cluster-wide early_out switch changes behaviour for everyone. For most systems, coarse grants plus a service that enforces record-level rules, or visibility labels, are simpler to reason about. Choose cell ACLs when the grants really are per record and you control every write path.
What to do next
- Check
hfile.format.versionandhbase.security.access.early_outon every RegionServer and on the target table. - List who holds table and family grants on the table; those users are not restricted by cell ACLs.
- Route every write to protected families through one function that attaches the full ACL.
- Give end users read access through cell ACLs only, and keep writes on service accounts with coarse grants.
- Add a test that writes as one user and reads as allowed and denied users, expecting data and empty results.
- Confirm replication and backup paths carry tags.
- Re-evaluate whether visibility labels would express the same policy with less rewriting.