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 (4096 bytes, enforced — see Rails compatibility details) and is visible to / stored by the client (encrypted + authenticated, so it can't be read or forged without the secret).
- Writing the session means writing a header, so it happens once per request via a required handler (
use Kemal::Session::Handler.new) rather than on every assignment — see When the cookie is written.
Installation
dependencies:
kemal-cookie-session:
github: hugopl/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
# Required: writes the session cookie once per request. Without it, mutating a
# session raises `Kemal::Session::HandlerRequired`.
use Kemal::Session::Handler.new
get "/" do |env|
count = env.session.int?("count") || 0_i64
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 |
CSRF-specific options (see CSRF):
| Option | Type | Default |
|---|---|---|
csrf_per_form_tokens |
Bool |
true |
csrf_origin_check |
Bool |
true |
csrf_param_name |
String |
"authenticity_token" |
csrf_trust_forwarded_headers |
Bool |
true |
csrf_base_url |
String? |
nil |
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.secure—false, the same default as Rails, which leans onforce_sslinstead. Set it totruefor any app served over HTTPS: without it the session cookie is sent over plain HTTP too, where it can be captured.csrf_per_form_tokens— must match the Rails app’sconfig.action_controller.per_form_csrf_tokens(trueunderload_defaults 5.2or later).csrf_origin_check— Rails’forgery_protection_origin_check.csrf_param_name— Rails’request_forgery_protection_token.csrf_trust_forwarded_headers— whetherX-Forwarded-Proto/X-Forwarded-Hostare believed when deriving the base URL theOriginheader is compared against.truematches Rails’request.base_urland is safe behind a proxy that overwrites those headers; set it tofalseif anything can reach the app directly, so a spoofed forwarded host can’t satisfy the check. Ignored whencsrf_base_urlis set.csrf_base_url— what theOriginheader is compared against, e.g."https://app.example.com". Derived fromX-Forwarded-Proto/Hostwhennil; set it explicitly if your proxy doesn’t forward the original scheme — otherwise the scheme falls back tohttpand every POST fails the check (the raised message names both URLs it compared).
Session values (kemal-session compatible)
For each of int (Int64), string, float (Float64) and bool:
env.session.int("k") # => Int64 (raises KeyError if absent/wrong type)
env.session.int?("k") # => Int64? (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)
A whole number is an integer, not a float, so float? returns nil for one.
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.
Complex objects
Anything that serializes to JSON — a JSON::Serializable object, an Array or Hash of them, a primitive — goes in with object and comes back out with the as: keyword:
struct Cart
include JSON::Serializable
# ...
end
env.session.object("cart", cart) # store (serialized as nested JSON)
env.session.object("cart", as: Cart) # => Cart (raises KeyError if absent)
env.session.object?("cart", as: Cart) # => Cart? (nil if absent/incompatible)
env.session.object("items", as: Array(Item)) # any type with `from_json`
env.session.delete("cart") # deletion is type-agnostic
This differs from kemal-session, which stores objects in a type-tagged container behind the Kemal::Session::StorableObject mixin. Here the value is stored as plain nested JSON, so Rails sees an ordinary nested Hash under session[:cart] — and the reader takes the class instead of consulting a global type registry.
Lifecycle
env.session.id # the Rails `session_id` (generated on first use)
env.session.destroy # clear + expire the cookie
env.session.destroyed? # true once destroyed, until reset
env.session.reset # clear + new session_id (lifts a previous destroy)
env.session.store # the underlying Hash(String, JSON::Any)
destroy is sticky: the cookie stays expired for the rest of the request even if something repopulates the session afterwards — a logout handler rendering a layout with csrf_meta_tags, say. Use reset when you want a fresh session rather than none.
When the cookie is written
Kemal::Session::Handler writes it, once, immediately before the response body — so a route that sets twenty keys pays for one encryption, not twenty. Mutations in between only mark the session dirty.
use Kemal::Session::Handler.new # before any handler that uses the session
Two things raise rather than losing a session silently:
HandlerRequired— the session was mutated with no handler in the chain, so nothing would ever have written the cookie.ResponseAlreadySent— the session was mutated after the response body started.Set-Cookieis a header, so it cannot change once the first bytes are out; mutate the session before writing the response. (Rails raisesActionDispatch::IllegalStateErrorhere for the same reason.)
Because the write is deferred, CookieOverflow surfaces at that commit rather than from the setter that made the session too big. env.session.save still writes immediately if you need it — useful only before the body starts.
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)
CSRF (Rails authenticity tokens)
A port of ActionController::RequestForgeryProtection with its SessionStore token strategy, so authenticity tokens are exchangeable with Rails in both directions: a form rendered by Rails posts successfully to a Kemal route, and a form rendered by Kemal posts successfully to a Rails controller.
Verifying incoming requests. Prefer the handler: protecting everything by default and exempting deliberately is what Rails does, and it means a new route is safe before you remember it exists.
use Kemal::Session::CSRF::Handler.new
# The handler raises; you choose the response. Rails answers 422 — without an
# error handler Kemal renders its generic 500 (the exception page outside production).
error Kemal::Session::InvalidAuthenticityToken do |env|
env.response.status_code = 422
"Invalid authenticity token"
end
GET and HEAD are exempt (and, exactly like Rails, OPTIONS/TRACE are not). Tokens are read from the X-CSRF-Token header and from the authenticity_token parameter in the body, query string or JSON body. Verification fails closed: a request with no session cookie has no token to match, so it is rejected.
Routes that genuinely can't carry a token — a webhook, or an API authenticated by a header instead of the session cookie — are exempted explicitly. except matches the request path (strings exactly, regexes by pattern), and skip covers what a path can't express:
handler = Kemal::Session::CSRF::Handler.new(except: ["/webhooks/stripe", /\A\/api\//])
handler.skip = ->(env : HTTP::Server::Context) do
!!env.request.headers["Authorization"]?.try(&.starts_with?("Bearer "))
end
use handler
For a one-off check inside a route — or when you'd rather handle the failure yourself — the request-level API is still there:
post "/items" do |env|
env.verify_csrf! # raises Kemal::Session::InvalidAuthenticityToken
# ...or: return unless env.csrf_verified?
end
Issuing tokens for pages Kemal renders:
env.csrf_token # masked global token
env.csrf_token(action: "/items", method: "post") # per-form token
env.csrf_hidden_field("/items", "post") # <input type="hidden" ...>
env.csrf_meta_tags # <meta name="csrf-token" ...> for Turbo
Rotate the token after a privilege change (Rails' reset_csrf_token):
Kemal::Session::CSRF.reset(env.session)
How it works, all of it byte-compatible with Rails:
- the real token lives in the session at
_csrf_token, as unpadded urlsafe base64 of 32 random bytes (SecureRandom.urlsafe_base64(32)); - a token handed to a client is masked —
raw = HMAC-SHA256(real, identifier), emitted asurlsafe_base64(pad + (pad XOR raw))with a fresh 32-byte pad, so the value differs per response (BREACH mitigation); identifieris"!real_csrf_token"for a global token, or"<action_path>#<method>"for a per-form token;- verification unmasks and accepts the global token, the real token, or the per-form token for the current path and method.
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). - Size limit: 4096 bytes, measured like Rails'
check_for_overflow!— the cookie name plus the raw encrypted value, before URL escaping. Past it,saveraisesKemal::Session::CookieOverflow(Rails'CookieOverflow) rather than emitting a cookie the browser would silently drop.
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.
CSRF tokens are verified the same way, against a live oracle driving the real ActionController::RequestForgeryProtection (needs Ruby + actionpack; skipped automatically when absent).
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
- 27 days ago
- July 24, 2026
MIT License
Wed, 29 Jul 2026 19:33:58 GMT