DEV Community

authagonal
authagonal

Posted on • Originally published at authagonal.io

We run a whole auth system on stores that only know get and put

An auth system looks like it wants a relational database. Users, roles, sessions, OAuth grants, refresh tokens, all cross-referenced. Ours does not use one. It runs on Azure Table Storage, a store that gives you a partition key, a row key, and almost nothing else. No joins, no meaningful secondary indexes, no increment operator, no multi-row transactions in the shape you are used to. That was a choice. Here is what the choice buys, the patterns that make it work, and the part where SQL would genuinely have been easier.

The reason to do this is cost and operational simplicity. No connection pool to exhaust, no database instance to size or patch or fail over, per-partition scaling you get for free. A table costs almost nothing to keep and there is nothing to run out of. The price of that is a store with no query planner, which only pays off when your access patterns are as predictable as the store is dumb. Auth's are, so the whole design leans into it.

The key does the work a table layout would. With no joins, you design the key so the query is the key. You denormalize on purpose, you pick partitions so your hot reads are point-gets, and you encode composite identities directly into the row key, for example "{pk}|{rk}" for entries that need a compound identity in a store that offers only one row-key slot. The question "how do I look this up" has to be answered when you design the key, not at query time. For auth that is fine, because the access patterns are known and they do not drift: get a user by id, get a user's grants, consume a code. You are not exploring the data. You know every question in advance.

You keep your own change log. Every row carries a server-managed Timestamp, and it is tempting to build incremental backup on it: pull everything where Timestamp gt watermark. That works in a demo and dies in production, because the store indexes the partition key and the row key and nothing else. Timestamp is not in any index, so filtering on it inspects every row in the table. It is a full scan wearing the costume of a query, and it gets slower every single day the table grows. So the store writes its own change log instead. Every mutation, every upsert and every delete, appends a row to a change-log table keyed by the logical table name, with the composite "{pk}|{rk}" as the row key and a flag for whether the row was written or deleted. Now "what changed since the watermark" is a read of one small, indexed partition instead of a scan of the whole table, and the backup point-reads only the rows that actually moved. You rebuild change-data-capture out of ordinary writes, and it scales with the churn instead of with the size of the table.

Concurrency without transactions. There is no SELECT ... FOR UPDATE here. What you get instead is one atomic primitive: the conditional write, handed to you as an ETag. You write a row only if the copy you read has not changed underneath you. Everything that needs safety under concurrent access is expressed as optimistic concurrency plus retry on conflict. The clearest example is the account-lockout counter. AccessFailedCount needs to increment atomically, but the store has no increment. So you read the row, bump the count, write it back conditional on the ETag you read, and retry if someone beat you to it. That loop is how you make a counter atomic on a store that has no counter. Fire fifty bad passwords at once and every one of them lands, because the conflict is detected and retried rather than lost.

A delete can be a lock. Single-use consumption, an authorization code or a one-time token that must be redeemed exactly once, is a conditional delete. It is an ETag conditional-delete: if the delete succeeds you won the race and may proceed; if it fails because the row was already gone, someone else consumed it first and you stop. The delete is the lock. The same idea does leader election, built on a blob lease, a lock made out of an atomic write rather than a separate coordination service. You almost never need a lock service when the write itself is atomic.

Deletes have to be first-class, which is half of why the change log exists. A key-value store has no delete marker: a deleted row simply vanishes, so anything that scanned for changes could never even see that it left. A backup that keyed off row timestamps would quietly carry the deleted user forward forever. The change log records the delete as a delete, so a restore replays it instead of resurrecting it. That failure mode, a backup that faithfully brings back everyone you removed, is a whole story on its own and deserves its own post, but the pattern belongs on this list: in a store with no delete log, you keep the delete log yourself.

Now the other side of the ledger, what you give up. You give up ad-hoc queries: a genuinely new access pattern can mean a new key design or a full scan, because there is no query planner to save you. You give up joins, so you maintain denormalized copies by hand and own the consistency of that. You give up multi-row transactions, so you design every invariant to live inside a single item, because single-item atomicity is all you are given. If your data is deeply relational, or your access patterns are unknown and still moving, this is the wrong tool and it will hurt.

Which is exactly why it fits auth. A relational database earns its keep by answering questions you have not thought of yet. An auth system does not have those questions. The set of things you ask, get this user, consume this code, elect this leader, back up what changed, is small, known, and stable. Give up the query planner you were never going to use, and in return you get a storage layer that costs almost nothing to operate and has nothing to fail over. For most software that is a bad trade. For this, it is the right one.

These patterns are the storage engine under Authagonal: a key-value design that costs almost nothing to operate, which is a big part of how we put every feature on every plan.

Top comments (0)