kemal-cookie-session

Sessions for Kemal stored in an AES-256-GCM encrypted cookie.

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 define Kemal::Session and env.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) and session.string("n", "x") refer to the same key "n".
  • There is no storage engine and no GC, so config.engine / config.gc_interval and the server-side, id-based lookup/enumeration methods from kemal-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 Rails secret_key_base. Also aliased as secret_key_base.
  • cookie_name — Rails session key; drives the message purpose cookie.<name>.
  • salt — Rails’ default authenticated-encrypted-cookie salt.
  • iterations — PBKDF2 iteration count (Rails’ app key generator default).
  • timeoutnil emits a session cookie with a null metadata expiry (matches default Rails); when set, adds an Expires and a metadata exp.

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, iterate env.session.store (a Hash(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

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

License

MIT

Repository

kemal-cookie-session

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • about 2 hours ago
  • July 24, 2026
License

MIT License

Links
Synced at

Fri, 24 Jul 2026 19:27:32 GMT

Languages