kemal-cookie-session
kemal-cookie-session
A cookie-store session for Kemal: the entire session lives inside a single AES-256-GCM encrypted, tamper-proof cookie, with nothing stored server-side — no storage engine, no session registry, no GC.
The public API mirrors the kemal-session shard, so it's a drop-in replacement for the common cases.
As a bonus, the cookie's wire format is byte-compatible with Rails 8's ActionDispatch::Session::CookieStore, so a Kemal app and a Rails 8 app that share the same secret_key_base and cookie name can read and write each other's sessions.
⚠️ Cannot be used at the same time as
kemal-session— both defineKemal::Sessionandenv.session. Pick one.
How it differs from kemal-session
kemal-session is a server-side store: the cookie holds only a signed session id and the data lives in a storage engine (memory/file/redis). This shard is a client-side store: the entire session lives inside the encrypted cookie, exactly like Rails. Consequences:
- The session hash is flat (Rails-shaped). A key lives in one namespace, not one-per-type:
session.int("n", 1)andsession.string("n", "x")refer to the same key"n". - There is no storage engine and no GC, so
config.engine/config.gc_intervaland the server-side, id-based lookup/enumeration methods fromkemal-session(Session.all,Session.get(id),Session.each,Session.destroy(id),Session.destroy_all) have no meaning here and are omitted. - Session data is limited to what fits in a cookie (~4 KB) and is visible to / stored by the client (encrypted + authenticated, so it can't be read or forged without the secret).
Installation
dependencies:
kemal-cookie-session:
github: your-github-user/kemal-cookie-session
Then shards install.
Usage
require "kemal"
require "kemal-cookie-session"
Kemal::Session.config do |c|
c.secret = ENV["SECRET_KEY_BASE"] # your Rails secret_key_base
c.cookie_name = "_myapp_session" # must match Rails `config.session_store key:`
end
get "/" do |env|
count = env.session.int?("count") || 0
env.session.int("count", count + 1)
"You have visited #{count + 1} times"
end
post "/login" do |env|
env.session.int("user_id", 42)
env.session.bool("admin", true)
env.redirect "/"
end
get "/logout" do |env|
env.session.destroy
env.redirect "/"
end
Kemal.run
Sharing sessions with a Rails 8 app
Use the same secret and cookie name on both sides:
# Rails: config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store, key: "_myapp_session"
# SECRET_KEY_BASE must be identical to the Kemal app's `config.secret`.
A value written in Kemal as env.session.int("user_id", 42) is readable in Rails as session[:user_id] == 42, and vice-versa.
API
Configuration (Kemal::Session.config)
| Option | Type | Default |
|---|---|---|
secret |
String |
"" (required) |
cookie_name |
String |
"_session_id" |
salt |
String |
"authenticated encrypted cookie" |
iterations |
Int32 |
1000 |
timeout |
Time::Span? |
nil |
secure |
Bool |
false |
http_only |
Bool |
true |
domain |
String? |
nil |
path |
String |
"/" |
samesite |
HTTP::Cookie::SameSite? |
Lax |
Per-option notes:
secret— your Railssecret_key_base. Also aliased assecret_key_base.cookie_name— Rails session key; drives the message purposecookie.<name>.salt— Rails’ default authenticated-encrypted-cookie salt.iterations— PBKDF2 iteration count (Rails’ app key generator default).timeout—nilemits a session cookie with anullmetadata expiry (matches default Rails); when set, adds anExpiresand a metadataexp.
Session values (kemal-session compatible)
For each of int (Int32), bigint (Int64), string, float (Float64) and bool:
env.session.int("k") # => Int32 (raises KeyError if absent/wrong type)
env.session.int?("k") # => Int32? (nil if absent/wrong type)
env.session.int("k", 1) # set + persist
env.session.delete("k") # delete a key (type-agnostic, since the store is flat)
Note: unlike
kemal-session, the plural bulk accessors (ints,strings,bools, …) are not provided. Because the store is flat and Rails-shaped rather than namespaced per type, iterateenv.session.store(aHash(String, JSON::Any)) directly if you need every value.
Lifecycle
env.session.id # the Rails `session_id` (generated on first use)
env.session.destroy # clear + expire the cookie
env.session.reset # clear + new session_id
env.session.store # the underlying Hash(String, JSON::Any)
Flash
A minimal, read-once flash (kemal-session compatible; not Rails' FlashHash):
env.flash["notice"] = "Saved!"
env.flash["notice"]? # => "Saved!" (then nil on the next read)
Rails compatibility details
The cookie is produced/consumed exactly as Rails 8 does by default:
- Key:
PBKDF2-HMAC-SHA1(secret_key_base, "authenticated encrypted cookie", 1000)→ 32 bytes. - Cipher: AES-256-GCM, 12-byte IV, 16-byte auth tag, no AAD.
- Wire:
strict_base64(ciphertext)--strict_base64(iv)--strict_base64(tag), then CGI/URL-escaped in the header. - Plaintext: the legacy metadata envelope
{"_rails":{"message":"<base64(session_json)>","exp":<iso8601|null>,"pur":"cookie.<key>"}}. - Serializer: JSON (
cookies_serializer = :json).
This is verified by the test suite both against a checked-in genuine Rails 8 fixture and, when Ruby + ActiveSupport are installed, against a live oracle in both directions.
Complex objects (not yet implemented)
kemal-session supports storing custom objects via session.object(k) / object(k, v) and the Kemal::Session::StorableObject mixin. That is not implemented yet here. Because this is a Rails-compatible flat JSON store, the natural design is to serialize objects as nested JSON rather than kemal-session's type-tagged container. Proposed API:
# Store any JSON::Serializable value under a key (serialized as nested JSON,
# which Rails would see as a nested Hash under session[:key]):
env.session.object("cart", cart) # cart : Cart (include JSON::Serializable)
env.session.object("cart", Cart) # => Cart (typed read; raises if absent/incompatible)
env.session.object?("cart", Cart) # => Cart?
# Rationale:
# - Nested JSON keeps byte-compatibility with Rails (a Ruby Hash on the Rails side).
# - Passing the class to the reader gives a typed result without a global
# union/registry (kemal-session's StorableObjects), fitting Crystal's generics.
# - `delete(k)` already removes object keys too (deletion is type-agnostic).
An alternative closer to kemal-session is to keep a StorableObject union and a type-tagged { "type": ..., "object": ... } envelope, at the cost of Rails interop for those keys. Feedback welcome before implementing.
Development
crystal spec # runs unit + interop specs (interop auto-skips without Ruby/ActiveSupport)
crystal tool format
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Hugo Parente Lima - creator and maintainer
License
MIT
kemal-cookie-session
- 0
- 0
- 0
- 0
- 1
- about 2 hours ago
- July 24, 2026
MIT License
Fri, 24 Jul 2026 19:27:32 GMT