Sessions in DynamoDB, no Redis
// written in 2023: tools, versions and prices may have changed since.
Part of my Krabber series, a Twitter clone in Go. The full source is on GitHub.
Intro
The usual advice for web sessions is “put them in Redis.” And Redis is great. But it’s also one more thing to run, secure, back up, and pay for, and for Krabber I didn’t want another box in the diagram. So sessions live in the same single DynamoDB table as the molts and the krabs. In this post I’ll walk through how, and the handful of rules that keep it correct, because a session store is one of those things that’s easy to get subtly wrong.
I use the alexedwards/scs package with a custom store swapped in for its in-memory default. scs handles the cookie and the session lifecycle; my store just teaches it how to read and write to DynamoDB.
I. The item design
A session is one item. Here’s what’s in it:
| Attribute | Value |
|---|---|
PK | S# + the SHA-256 of the session token |
SK | S# |
data | scs’s encoded session data |
expires_at | Unix seconds, used for TTL cleanup |
The important detail is the first row. I store the SHA-256 of the token, never the token itself. So if someone ever reads the table, or gets hold of a backup, they can’t lift a live session out of it, because what’s in there is a hash, not the cookie value. It’s the same reason you hash passwords: the database should never hold the thing that grants access.
II. The rules that make it correct
A session store has a few sharp edges. These are the ones I made sure to get right.
Check expiry on every read. My lookup treats expires_at <= now as “not found,” full stop. DynamoDB’s TTL deletion can lag by up to about 48 hours, so I never trust it as the security check. TTL is just the janitor that sweeps up expired items eventually. The real expiry check happens in code, every time.
Read strongly consistent. The redirect right after login has to see the session that login just wrote, so the read uses ConsistentRead: true. Without it you can get a maddening bug where logging in sometimes bounces you back to the login page, because the read hit a replica that didn’t have the write yet.
Rotate the token on login and logout. Both events delete the old session and start a new one, so a token can’t outlive the moment it should. This is standard session-fixation defense, and it’s tested explicitly.
Log out everywhere, cheaply. The krab record carries a sessions_valid_after timestamp, and each session remembers when it was created. If a session is older than that line, it’s rejected. Changing your password, resetting it, or getting banned just sets sessions_valid_after to now, and every other session the krab has goes dead on its next request. No scanning the table to hunt down sessions.
Make the common path one read. The session stores the krab’s own PK/SK, so authenticating a request is a single GetItem instead of a secondary-index lookup every time. Sessions get read on nearly every request, so this is the hot path, and keeping it to one cheap read matters for both speed and cost.
Lock down the cookie. It’s named __Host-krabber_session, which forces Secure, a / path, and no domain. Plus HttpOnly and SameSite=Lax. In local dev (APP_ENV=dev) I turn the Secure and __Host- requirements off, because localhost is plain HTTP and the browser would otherwise refuse the cookie.
III. Why this beats a second datastore, here
The payoff is bigger than “one less service.” Because sessions are just items in the main table, they inherit everything that table already has: point-in-time recovery, the throughput caps, TTL cleanup, the same backups. And because the design is stateless, sessions survive a deploy or an instance replacement without a blip. When Krabber does an immutable deploy and swaps in a fresh box, nobody gets logged out, because the sessions were never on the box in the first place.
There’s a cost angle too. scs only writes a session when its data actually changes, so plain page views don’t cost a write. A logged-in krab reading their feed is all reads, no writes, which keeps the session store nearly free at hobby traffic.
Conclusion
Keeping sessions in DynamoDB isn’t a clever hack, it’s just refusing to add a moving part I didn’t need. The table was already there, already backed up, already paid for, so I taught it to hold sessions too. Hash the token so the store never holds the keys to the kingdom, check expiry in code instead of trusting TTL, and read consistently right after login, and you get a session store that’s boring in the best way. If you’re reaching for Redis out of habit on a small app, check whether your main database can just do it. Thanks for reading, and may your sessions stay yours.