Usage Metering in Go SDKs: Cryptographic Receipts, Tamper-Evident Counters, and the Offline Grace Problem
Enterprise Go SDKs—the kind embedded in a customer's binary and shipped inside their infrastructure—face a metering problem that pure web services avoid entirely: the process you are counting runs inside a host you do not control. You cannot simply read a counter in Redis. You cannot call home on every request. You must count accurately, resist tampering, survive network partitions, and still enforce limits without becoming a reliability liability for your customer's production stack.
This article works through the concrete mechanics of doing that in Go: what the runtime gives you, where it fails, how to build tamper-evident local state, and when cryptographic receipts become the right primitive instead of a real-time reporting pipeline.
Why the Metering Problem Is Harder Inside an SDK
A SaaS backend controls its own data plane. An SDK does not. Once you ship a .so or a statically linked Go binary, the calling process owns the address space, the file system, the clock, and the network. An adversarial operator can:
- Replace your metering goroutine's ticker with a patched clock.
- Delete or replay the local persistence file that tracks accumulated usage.
- Firewall the reporting endpoint and wait for your grace window to expire gracefully.
- Fork the process at a known-low counter state and restore it after heavy usage.
cgo makes some of these attacks marginally harder to script but introduces its own attack surface (symbol interposition, LD_PRELOAD, ABI compatibility). Pure-Go SDKs are easier to audit, build reproducibly, and cross-compile—but they are also fully introspectable with go tool objdump and patchable at the binary level. Neither choice eliminates the threat model; it only shifts where the risk concentrates.
The honest engineering answer is: assume the local process is hostile; design metering so that integrity is verifiable externally, not asserted locally.
Atomic Counters and the Persistence Contract
The first layer is a correct in-process counter. In Go, the idiomatic primitive is sync/atomic over int64:
type Meter struct {
calls atomic.Int64
bytes atomic.Int64
flushOnce sync.Once
mu sync.Mutex
sealed []Receipt // signed snapshots pending upload
}
func (m *Meter) RecordCall(payloadBytes int64) {
m.calls.Add(1)
m.bytes.Add(payloadBytes)
}
atomic.Int64 (Go 1.19+) is cache-line aligned by the compiler when embedded in a struct—no false sharing penalty on the hot path. This matters when the SDK is called from hundreds of goroutines across a gRPC server's request handlers.
The counter alone is worthless without durable snapshots. Every N calls or every T seconds, the SDK must flush to local disk:
func (m *Meter) snapshot(key ed25519.PrivateKey) Receipt {
r := Receipt{
Calls: m.calls.Load(),
Bytes: m.bytes.Load(),
IssuedAt: time.Now().UTC(),
HostID: deriveHostID(), // SHA-256 of machine-id or EKS node identity
}
payload, _ := json.Marshal(r)
r.Sig = ed25519.Sign(key, payload)
return r
}
The receipt is Ed25519-signed with a key the SDK holds at initialization—injected at SDK construction time from a license blob, never written to the file system in plaintext. The signature binds the counter value to a host identity at a specific wall-clock time. An attacker who rewinds the file to an earlier receipt cannot produce a valid signature for the current HostID and timestamp combination without the private key.
Writing to disk uses O_SYNC or an explicit Sync() call on the file descriptor before the old file is renamed away, because the Go standard library's os.WriteFile does not fsync the parent directory. A power loss between write and rename produces a zero-byte receipt file—losing the snapshot period's data. The fix:
func atomicWrite(path string, data []byte) error {
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil { return err }
if _, err = f.Write(data); err != nil { f.Close(); return err }
if err = f.Sync(); err != nil { f.Close(); return err }
f.Close()
dir, _ := os.Open(filepath.Dir(path))
defer dir.Close()
if err = os.Rename(tmp, path); err != nil { return err }
return dir.Sync() // sync directory entry on Linux ext4/xfs
}
On Kubernetes, the SDK's writable path must be a emptyDir or a mounted PVC—not the container's overlay filesystem, which may not preserve fsync ordering semantics across node evictions.
The Offline Grace Problem
Something will block your reporting endpoint: a VPC firewall rule, a proxy misconfiguration, a transient AWS PrivateLink outage. You need a grace window. The tradeoffs:
| Grace window | Operator risk | SDK reliability risk |
|---|---|---|
| 0 (strict) | None | Breaks on any network blip |
| 1 hour | Low | Acceptable for most SLAs |
| 24 hours | Medium | Standard enterprise expectation |
| 7 days | High | Meaningful evasion surface |
| Unbounded | Unacceptable | Not metering, just logging |
The right answer is context-dependent, but 24–48 hours covers legitimate outages without giving a determined operator a meaningful evasion window. The grace state must itself be signed and stored: the SDK records GraceStartedAt in the receipt chain the moment reporting fails, and enforces a hard cutoff independently of wall-clock drift by verifying against the sequence of signed snapshots.
Clock manipulation is a real attack. An operator can set TZ or even CLOCK_REALTIME (with CAP_SYS_TIME) to keep time.Now() in the past. Countering this: embed a monotonic receipt sequence number that increments with every snapshot. If the server receives receipt #47 after previously receiving #201, the sequence regression is itself evidence of replay. The server rejects it and triggers enforcement.
Reporting Pipeline: What the Backend Receives
The reporting endpoint is a plain HTTPS POST. The SDK batches unsent receipts and ships them:
POST /v1/meter/receipts
Content-Type: application/json
{
"license_id": "lic_abc123",
"receipts": [
{
"calls": 148203,
"bytes": 984321048,
"issued_at": "2026-08-11T04:00:00Z",
"host_id": "sha256:deadbeef...",
"seq": 47,
"sig": "base64..."
}
]
}
The backend verifies each receipt's Ed25519 signature against the public key stored in the license record (MongoDB document, indexed on license_id). It then:
- Checks sequence monotonicity per
(license_id, host_id)pair. - Aggregates
callsandbytesinto a time-series store (a capped collection or a write to a columnar sink like Redshift via Kinesis Firehose for billing). - Returns a signed acknowledgment the SDK persists locally, marking those receipts delivered.
- Evaluates whether the license's call quota is exceeded and sets an enforcement flag in Redis with a TTL equal to the grace window.
The SDK polls a separate lightweight endpoint (GET /v1/license/status) to pick up enforcement decisions. Separating the reporting write-path from the enforcement read-path means a reporting pipeline outage does not cause spurious enforcement, and enforcement decisions can be cached aggressively at the SDK with a short TTL.
License Enforcement Without a Hard Network Dependency
Hard-stopping a production service because a metering endpoint is unreachable is an unacceptable failure mode. The enforcement model should be:
- Soft enforcement: log, emit a metric, alert via the SDK's registered callback. Never block traffic within the grace window.
- Hard enforcement: only after the signed receipt chain proves the grace window has expired and connectivity was available (because the SDK received at least one successful ACK during that window).
This is the key distinction. If the SDK never received a single successful ACK within the grace window—meaning the endpoint was unreachable the entire time—enforcement should remain soft until connectivity returns. The adversarial case—receipts delivered successfully, quota exceeded, enforcement flag set—is the case for hard cutoff.
Implementing this requires the SDK to maintain a LastSuccessfulReport timestamp in its persisted state, also signed. The enforcement decision tree:
if quota_exceeded AND (now - LastSuccessfulReport) < grace_window:
soft_enforce() // warn, emit metric
else if quota_exceeded AND (now - LastSuccessfulReport) >= grace_window:
hard_enforce() // return error on API calls
else if NOT quota_exceeded:
allow()
Decision Framework
Use signed local receipts (Ed25519) when:
- Your SDK runs in customer infrastructure without guaranteed egress.
- Replay and rollback attacks are in your threat model.
- You need an audit trail that survives SDK restarts and process crashes.
Use a real-time reporting pipeline (Kinesis/Kafka) when:
- You control the deployment environment (e.g., your own multi-tenant SaaS).
- Latency to the metering backend is predictable and low.
- The cost of a brief enforcement window is acceptable.
Set grace windows based on your customer's operational profile, not your preferred business risk. A 24-hour grace window with signed sequence numbers gives you integrity guarantees while covering every realistic network outage scenario. Extending it beyond 72 hours requires a compensating control—such as cryptographic attestation from the host (TPM-backed or AWS Nitro Enclave attestation) to prevent clock and replay manipulation.
Avoid storing the private signing key in plaintext. Inject it from the license blob at SDK initialization, hold it in a []byte that is zeroed after the first snapshot, and derive subsequent snapshot-signing material via HKDF from the original key material and the receipt sequence number. This limits the blast radius of a memory dump to a single snapshot window rather than the full receipt chain.
Metering inside an SDK is fundamentally a distributed systems problem dressed in security clothing. The counter is easy. The integrity guarantee across a partition, a hostile process, and a variable grace window is the engineering problem worth solving carefully.
Top comments (0)