Amazon DynamoDB Integration Guide
Connection Configuration
BurnLedger connects to DynamoDB using AWS IAM credentials.
Configuration fields:
| Field | Required | Description |
|---|---|---|
table |
Yes | The DynamoDB table name. |
region |
Yes | AWS region (e.g., us-east-1). Not local: the AWS endpoint ruleset resolves region: "local" to http://localhost:8000 even with no endpoint set, which classifies plaintext and is refused. |
access_key_id |
Yes | IAM access key ID. |
secret_access_key |
Yes | IAM secret access key. |
endpoint |
No | Custom endpoint for DynamoDB Local or compatible services. Must be https:// — an http:// endpoint classifies plaintext and the connector is refused. Omit it and the SDK resolves the real AWS endpoint over https://, which is what production should do. |
read_only |
Conditional | Your assertion that the credential is read-only. Required whenever BurnLedger cannot run IAM permission simulation — i.e. whenever endpoint is set, or the credential lacks iam:SimulatePrincipalPolicy. See IAM Policy. |
These are the exact JSON keys the connector reads. They are snake_case in both SDKs — do not write accessKeyId, secretAccessKey, or readOnly; the connection object is sent to the API verbatim and unknown keys are ignored, so a camelCase key reads as a missing credential.
TypeScript
const system = await bl.registerSystem({
name: "user-profiles",
connectorType: "dynamodb",
connectionConfig: {
table: "UserProfiles",
region: "us-east-1",
access_key_id: process.env.DP_DYNAMO_ACCESS_KEY_ID,
secret_access_key: process.env.DP_DYNAMO_SECRET_ACCESS_KEY,
},
subjectQuery: '{"pk": "$IDENTIFIER"}',
});
Python
system = bl.register_system(
name="user-profiles",
connector_type="dynamodb",
connection_config={
"table": "UserProfiles",
"region": "us-east-1",
"access_key_id": os.environ["DP_DYNAMO_ACCESS_KEY_ID"],
"secret_access_key": os.environ["DP_DYNAMO_SECRET_ACCESS_KEY"],
},
subject_query='{"pk": "$IDENTIFIER"}',
)
Query Template Format
DynamoDB query templates are JSON objects defining the key condition. Use "$IDENTIFIER" as the placeholder for the data subject.
Examples:
// Query by partition key
{"pk": "$IDENTIFIER"}
// Query by partition key with a sort key prefix
{"pk": "$IDENTIFIER", "sk_prefix": "PROFILE#"}
// Query by GSI
{"index": "email-index", "email": "$IDENTIFIER"}
Reserved keys. Two keys in the template object are directives rather than key conditions:
| Key | Meaning |
|---|---|
index |
Query this GSI instead of the base table. |
sk_prefix |
Add a begins_with condition on the sort key. The sort key attribute must literally be named sk — the connector hardcodes that name and does not read the table's actual sort-key attribute. If your sort key has another name, express the condition as a normal template key instead (that gives equality, not prefix matching). |
Every other key becomes an equality condition (attr = value), all joined with AND.
How it works:
- BurnLedger replaces
"$IDENTIFIER"with the subject value. The template must contain$IDENTIFIER; without it the query is rejected withINVALID_QUERY_TEMPLATErather than scanning the whole table. - Executes a
Queryoperation (neverScan) against the table or GSI. - Paginates using
ExclusiveStartKey, stopping withRECORD_LIMIT_EXCEEDEDif the result set exceeds the system'smax_records.
Restorable copies
A DynamoDB deletion cannot be certified while the table has a restore path.
Query reads live items. It does not see either of the two copies DynamoDB
keeps beside the table:
- Point-in-time recovery. With PITR enabled,
RestoreTableToPointInTimerebuilds the table as it was at any second in a trailing window of up to 35 days — including every item deleted inside it. - On-demand backups. Each backup is a full copy of the table as of the
moment it was taken. A backup from before the deletion still contains the
subject's items, and
RestoreTableFromBackupbrings them back.
So when a subject query returns no items, BurnLedger asks the table about both before it will report a zero:
- PITR enabled (or being enabled) → refused, error code
S3_VERSIONING_CONFLICT, withmechanism: point-in-time recovery. - Any backup of the table exists → refused,
mechanism: on-demand backups. - Neither, and both questions answered → the zero is reported and can be certified.
- Either question unanswerable (for example the credential lacks the
permission) → refused with
CONNECTION_FAILEDnaming the grant to add. BurnLedger never treats an unanswered question as a "no".
The check is per table, because that is what DynamoDB restores. Neither API can say whether a particular subject is inside a particular backup, so a table with a restore path is refused for every subject, including subjects who were never in it.
To certify a DynamoDB deletion: disable point-in-time recovery on the table and delete every on-demand backup of it, then re-run the attestation. Nothing is checked when the query finds records — a non-zero count fails the deletion claim on its own.
Schema drift
DynamoDB validates the key condition BurnLedger builds from your template, so
most ways a template can silently stop matching are refused by the service
rather than certified as zero: a template naming an attribute that is not the
table's key ({"user_id": "$IDENTIFIER"} against a table keyed on pk) is a
ValidationException, and a template naming a GSI the table no longer has is a
ResourceNotFoundException. Either surfaces as CONNECTION_FAILED; neither
becomes a certificate.
The one thing it does not validate is the sort-key value behind
sk_prefix. "sk_prefix": "PROFILE#" is a naming convention your
application writes into sk; if the application moves to profile# and the
template still says PROFILE#, the query is well-formed, matches nothing, and
a deletion attestation would certify a zero over items that are still there.
BurnLedger deliberately does not probe this. The only bounded question
available — do items exist under the subject's partition key with some other
sort-key prefix — is a question about the subject, not about the convention:
in the single-table design this guide recommends sk_prefix for, a subject who
legitimately retains items of another entity type under the same partition key
would be refused on every attestation, with no template that expresses the
scope they need. So the obligation is yours, and it is a small one: treat a
change to a sort-key prefix as a change to the registered query. Re-register
the system (or update the template) when the convention changes, and run an
attest that finds records before you rely on a verify that finds none.
Granted read scope
POST /v1/systems/test-connection reports, under read_scope, the tables IAM
would allow this credential to Query — the read-side twin of the write check
above, asked the same way (iam:SimulatePrincipalPolicy, a policy evaluation,
not an action) over the table names dynamodb:ListTables returns. A credential
whose policy reaches past the registered table is shown to; see
granted read scope for what the field is and is
not.
The policy above does not grant dynamodb:ListTables, on purpose, so with it
the scope reads Query on table UserProfiles plus ListTables not granted;
only the registered table could be checked — the breadth was not measured,
and the field says so rather than presenting one table as the whole grant.
Grant dynamodb:ListTables on * if you want the disclosure to cover every
table in the region. Against DynamoDB Local, or without
iam:SimulatePrincipalPolicy, nothing is reported. The scope is disclosure
only: no connection is refused for being broad.
Read consistency and endpoint role
Behaviour change. A query against the base table now sets
ConsistentRead, so it is answered by the partition leader. It previously used
DynamoDB's default, which is an eventually consistent read: one that may be
answered by a replica that has not yet received a write the table already
acknowledged. Against a subject's records that produces a certified "no records
found" over data that exists — an absence nothing downstream can detect, because
the certificate is authentic and the count is a real number. The cost is one
extra read capacity unit per 4 KB and no availability during a partition, and
both are the right trade for evidence.
ConsistentRead is not set when the template names an index. DynamoDB
rejects a consistent read on a global secondary index, and BurnLedger cannot
tell a global index from a local one without a DescribeTable on every query —
so an indexed template keeps the semantics it always had. A GSI lags its table
and can answer zero for a subject the table still holds.
POST /v1/systems/test-connection reports this under replication: role
primary, with the GSI exception named in detail. See
endpoint role.
IAM Policy
BurnLedger verifies that the credential cannot write to the table and rejects connections with write access — without ever writing. It resolves the table ARN with a read-only DescribeTable call, then asks IAM via iam:SimulatePrincipalPolicy whether the principal would be allowed dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:DeleteItem, or dynamodb:BatchWriteItem (a policy evaluation, not an action). If any would be allowed, construction is refused.
This means the credential needs the iam:SimulatePrincipalPolicy permission for the check to run. If you cannot grant it (or you point endpoint at DynamoDB Local, which has no IAM), set "read_only": true in the connector config to assert the credential is read-only; BurnLedger only ever reads regardless. Without either, the connection is refused (fails closed).
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BurnLedgerReadOnly",
"Effect": "Allow",
"Action": [
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:DescribeTable",
"dynamodb:DescribeContinuousBackups",
"dynamodb:ListBackups"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:123456789012:table/UserProfiles",
"arn:aws:dynamodb:us-east-1:123456789012:table/UserProfiles/index/*",
"arn:aws:dynamodb:us-east-1:123456789012:table/UserProfiles/backup/*"
]
}
]
}
The backup/* line is not optional either. dynamodb:ListBackups
authorizes against table/<name>/backup/*, not against the table ARN, so a
policy that lists only the table and its indexes grants DescribeTable,
Query, GetItem and DescribeContinuousBackups and still fails on
ListBackups:
AccessDeniedException: User: … is not authorized to perform:
dynamodb:ListBackups on resource:
arn:aws:dynamodb:…:table/UserProfiles/backup/*
That refusal arrives at verify, long after registration and attestation have both succeeded, because it is the only stage that asks whether a backup could restore what was deleted. This guide published the two-resource form until 2026-09-06 and it does not work.
dynamodb:DescribeContinuousBackups and dynamodb:ListBackups are required, not optional. Both are read-only — they are part of AWS's own AmazonDynamoDBReadOnlyAccess managed policy — and BurnLedger uses them for the restore check described under Restorable copies below. Without them an attestation that finds no records is refused, because a table whose restore state cannot be read is a table whose deletions cannot be certified.
Denied actions (BurnLedger checks for these):
dynamodb:PutItemdynamodb:UpdateItemdynamodb:DeleteItemdynamodb:BatchWriteItem
To let BurnLedger verify read-only access automatically, also allow iam:SimulatePrincipalPolicy (resource *). Otherwise, omit it and set "read_only": true in the connector config.
Verify
# Should succeed
aws dynamodb query --table-name UserProfiles --key-condition-expression "pk = :v" --expression-attribute-values '{":v":{"S":"user-123"}}' --profile burnledger-ro
# Should fail with "AccessDeniedException"
aws dynamodb put-item --table-name UserProfiles --item '{"pk":{"S":"test"}}' --profile burnledger-ro
Hash Scope Options
| Scope | Behavior |
|---|---|
full |
Hashes complete item content (all attributes). Attributes are sorted by name for deterministic hashing; nested maps, lists and sets are canonicalized recursively. |
hash_scopedoes nothing on DynamoDB, in either direction. The field is read only by the object-store connectors (S3, GCS, Azure Blob, MarkLogic). DynamoDB ignores it and canonicalizes the entire item, soexistencedoes not reduce what is read or hashed — worth knowing if you chose it expecting to avoid touching sensitive attributes.Registration now refuses
hash_scope: "full"here, because it would print a claim onto the verification record that no code implements. The control that works isproof_mode: "merkle"on the attestation: it hashes each item and publishes amerkle_rooton the verification record.proof_mode: "count"records only how many items matched.
DynamoDB-Specific Considerations
Single-table design
If you use single-table design, your query template should include a sort key prefix to scope to the correct entity type:
{"pk": "$IDENTIFIER", "sk_prefix": "USER#"}
Global Secondary Indexes (GSIs)
Query by GSI when the partition key is not the subject identifier:
{"index": "email-index", "email": "$IDENTIFIER"}
DynamoDB Local
Development deployments only — a production BurnLedger refuses this config twice over. An
http://endpoint classifiesplaintext, which is below the transport floor, andlocalhostis blocked by the SSRF-safe dialer. Running it takes bothDP_ALLOW_UNVERIFIED_TRANSPORT=trueandDP_ALLOW_PRIVATE_NETWORKS=trueon the server (and, in enclave mode, baked into the measured EIF). Neither belongs on a hosted deployment; see connector transport security. Do not copy this shape into a real system registration.
For local testing, use a custom endpoint:
connection: {
table: "UserProfiles",
region: "us-east-1",
access_key_id: "dummy",
secret_access_key: "dummy",
endpoint: "http://localhost:8000",
// Required: DynamoDB Local has no IAM, so permission simulation cannot run
// and the connector refuses to construct without this assertion.
read_only: true,
}
An S3-compatible or DynamoDB-compatible service reachable over https:// on a
public hostname needs neither flag: an https:// endpoint classifies verified
like the real AWS endpoint does.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
CONNECTION_FAILED on attestation: "connector dynamodb: transport security plaintext is below the required minimum verified". A health check reports only connection failed — read transport_security on the system |
endpoint is http://, or region is local (the AWS ruleset resolves that to http://localhost:8000 on its own) |
Use an https:// endpoint, or omit endpoint and name a real AWS region. |
CONNECTION_FAILED with "AccessDeniedException" |
IAM credentials lack required permissions | Add dynamodb:Query, dynamodb:GetItem, dynamodb:DescribeTable to the IAM policy. |
CONNECTION_FAILED: "cannot determine whether DynamoDB retains a recoverable copy" |
The credential cannot read the table's restore state | Add dynamodb:DescribeContinuousBackups and dynamodb:ListBackups; both are read-only. |
S3_VERSIONING_CONFLICT: "still holds a recoverable copy … via point-in-time recovery" |
PITR is enabled on the table, so deleted items are restorable | Disable PITR on the table, then re-run. See Restorable copies. |
S3_VERSIONING_CONFLICT: "still holds a recoverable copy … via on-demand backups" |
The table has at least one backup containing the subject's items | Delete the table's backups, then re-run. |
CONNECTION_FAILED with "ResourceNotFoundException" |
Table does not exist in the specified region | Verify table name and region. |
WRITE_ACCESS_DETECTED |
IAM user has PutItem/DeleteItem permissions |
Remove all write actions from the IAM policy. |
CONNECTION_FAILED with "cannot verify the credential is read-only" |
Permission simulation could not run — the credential lacks iam:SimulatePrincipalPolicy, endpoint is set (DynamoDB Local has no IAM), or DescribeTable returned no table ARN |
Grant iam:SimulatePrincipalPolicy, or confirm the credential is read-only yourself and set "read_only": true in the connector config. |
INVALID_QUERY_TEMPLATE |
Query template contains no $IDENTIFIER placeholder |
Add "$IDENTIFIER" as the value of the partition-key field. Without it the query would scan the whole table, so it is refused. |
CONNECTION_FAILED with "failed to parse query template" |
Template is not valid JSON | Fix the JSON syntax — the template must be a JSON object, e.g. {"pk": "$IDENTIFIER"}. |
RECORD_LIMIT_EXCEEDED |
The query matches more items than the system's max_records |
Narrow the template (add sk_prefix or a GSI) or raise max_records. |