π Powerful session management for Kemal web applications
Add secure, persistent session support to your Kemal web applications with just a few lines of code! Perfect for user authentication, shopping carts, temporary data storage, and more.
- π― Simple & Intuitive: Get started in minutes with a clean, easy-to-use API
- π Secure by Default: Built-in CSRF protection and signed session cookies
- ποΈ Fast & Flexible: Multiple storage engines (Memory, File, Redis, PostgreSQL, etc.)
- π§© Type-Safe: Support for all Crystal types plus custom objects
- π‘οΈ Production Ready: Automatic session cleanup and security best practices
Add kemal-session to your shard.yml:
dependencies:
kemal-session:
github: kemalcr/kemal-sessionThen run:
shards installrequire "kemal"
require "kemal-session"
# Session Configuration
Kemal::Session.config.secret = "my-secret-key"
# User login (create session)
post "/login" do |env|
username = env.params.body["username"]?.to_s
# In a real app you would authenticate here
env.session.string("username", username)
env.session.bool("logged_in", true)
"Welcome #{username}, you're now logged in."
end
# Protected route using session
get "/profile" do |env|
unless env.session.bool?("logged_in")
env.response.status_code = 401
next "Please log in first"
end
username = env.session.string("username")
"Hello #{username}!"
end
# User logout (destroy session)
post "/logout" do |env|
env.session.destroy
"You have been logged out."
end
Kemal.runrequire "kemal"
require "kemal-session"
# Session Configuration
Kemal::Session.config.secret = "my-secret-key"
# Add item to cart
post "/cart/add" do |env|
product_id = env.params.body["product_id"].as(String)
# Get existing cart or create new one
cart = env.session.object?("cart") || [] of String
cart << product_id
env.session.object("cart", cart)
"Item added to cart! Total items: #{cart.size}"
end
# View cart
get "/cart" do |env|
cart = env.session.object?("cart") || [] of String
if cart.empty?
"Your cart is empty"
else
"Your cart: #{cart.join(", ")} (#{cart.size} items)"
end
end
Kemal.runProtect your application from Cross-Site Request Forgery attacks with built-in CSRF middleware.
require "kemal"
require "kemal-session"
# Session Configuration
Kemal::Session.config.secret = "my-secret-key"
# Add CSRF protection
add_handler Kemal::Session::CSRF.new
get "/form" do |env|
csrf_token = env.session.string("csrf")
<<-HTML
<form method="POST" action="/submit">
<input type="hidden" name="authenticity_token" value="#{csrf_token}">
<input type="text" name="message" placeholder="Enter message">
<button type="submit">Submit</button>
</form>
HTML
end
post "/submit" do |env|
message = env.params.body["message"]
"Message received: #{message}"
end
Kemal.runThe token is created lazily, only when your application asks for it. The middleware itself never
creates one, so anonymous visitors, crawlers and unauthenticated POSTs leave nothing behind in
session storage. A state changing request that arrives without a session, or with a session that has
no token yet, is rejected with 403 instead of being handed a freshly minted token it could not
have matched anyway.
Reading the token materialises it, so the usage shown above keeps working as is. These are all equivalent:
env.session.string("csrf") # documented usage, creates the token on demand
env.session.csrf_token # same thing, more explicit
Kemal::Session::CSRF.token(env) # same thing, without going through the sessionenv.session.string?("csrf") stays a plain existence check and returns nil while no token has
been created yet.
The token is also sent to the client in a cookie (named after parameter_name) which inherits
secure, path, domain and samesite from Kemal::Session.config. Read the token before you
start writing the response body: once the headers are on the wire the cookie can no longer be added,
and the token would only exist server side.
Because nothing is created until you ask for it, an application that never reads the token has no
way to hand one out, and every state changing request is rejected with 403. If your frontend
builds its own forms, or you serve a JSON API, expose the token explicitly:
get "/csrf" do |env|
env.response.content_type = "application/json"
{token: env.session.csrf_token}.to_json
endCall it when the client starts up and send the value back in the X_CSRF_TOKEN header (or in the
parameter_name field). Crystal's HTTP::Headers treats _ and - as equivalent, so a browser
sending the conventional X-CSRF-Token header matches the default without any configuration. The
response also carries the token as a cookie, so a client that would rather read document.cookie
can do that instead. This is the same pattern as Spring Security's /csrf endpoint and Django's
ensure_csrf_cookie.
To make the token available across a group of pages, read it in a filter:
before_all "/app/*" do |env|
env.session.csrf_token
endScope the filter to the routes that need it. Running it on every route would create a session for every anonymous visitor, which is exactly the storage growth the lazy behaviour avoids.
# Customize CSRF behavior
add_handler Kemal::Session::CSRF.new(
header: "X-CSRF-TOKEN", # Custom header for AJAX requests
allowed_methods: ["GET", "HEAD", "OPTIONS"], # Methods that skip CSRF check
allowed_routes: ["/api/public"], # Public routes that skip CSRF
parameter_name: "_token", # Custom form field name
error: "Invalid or missing CSRF token", # Custom error message
per_session: false # see below
)If per_session is false (the default), the token changes after one single submission; this is
ideal for "classic" web pages that are fully loaded for each request.
If per_session is true the token is not automatically updated during a session's lifetime;
manual changes via env.session.string("csrf","new_token") are still possible.
This is useful for partial page updates with AJAX-based approaches like HTMX,
as you can change the CSRF token with each full page load while keeping it the same for partial operations.
# Custom error handler for JSON APIs
csrf_handler = Kemal::Session::CSRF.new(
error: ->(env : HTTP::Server::Context) {
env.response.content_type = "application/json"
env.response.status_code = 403
{"error" => "CSRF token required"}.to_json
}
)
add_handler csrf_handlerFlash messages are one-time messages that persist across a single redirectβideal for success notices, errors, or warnings after form submissions.
- Set a flash message in one request (e.g. after an action).
- Read it in the next request (e.g. the redirected page); the value is then removed automatically.
require "kemal"
require "kemal-session"
Kemal::Session.config.secret = "my-secret-key"
# Set a flash message (e.g. after login or form submit)
post "/login" do |env|
# ... authenticate user ...
env.flash["notice"] = "Welcome back!"
env.redirect "/dashboard"
end
# Read the flash in the next request (e.g. layout or target page)
get "/dashboard" do |env|
notice = env.flash["notice"]? # returns value and clears it
error = env.flash["error"]? # optional: nil if key missing
message = notice ? "<p class=\"notice\">#{notice}</p>" : ""
message += error ? "<p class=\"error\">#{error}</p>" : ""
"Dashboard\n#{message}"
end| Operation | Method | Behavior |
|---|---|---|
| Set | env.flash["key"] = "value" |
Stores the message for the next request |
| Read (optional) | env.flash["key"]? |
Returns the value and removes it; returns nil if key is missing |
| Read (required) | env.flash["key"] |
Returns the value and removes it; raises KeyError if key is missing |
Use env.flash["key"]? when the key might not be set (e.g. optional notices). Use env.flash["key"] when you expect the key to exist; it will raise if the message was already consumed or never set.
post "/contact" do |env|
if send_email(env.params.body)
env.flash["notice"] = "Message sent successfully."
else
env.flash["error"] = "Failed to send message."
end
env.redirect "/contact"
end
get "/contact" do |env|
notice = env.flash["notice"]?
error = env.flash["error"]?
# Render form and show notice/error above it
endKemal Session supports all common Crystal types with intuitive method names:
| Crystal Type | Session Method | Example |
|---|---|---|
Int32 |
session.int |
env.session.int("count", 42) |
Int64 |
session.bigint |
env.session.bigint("timestamp", 1234567890_i64) |
String |
session.string |
env.session.string("name", "Alice") |
Float64 |
session.float |
env.session.float("price", 19.99) |
Bool |
session.bool |
env.session.bool("logged_in", true) |
| Custom Objects | session.object |
env.session.object("user", user_obj) |
# Get values (raises if not found)
count = env.session.int("count")
name = env.session.string("username")
# Get optional values (returns nil if not found)
count = env.session.int?("count") # returns Int32 or nil
name = env.session.string?("username") # returns String or nil
# Provide default values
count = env.session.int?("count") || 0
theme = env.session.string?("theme") || "light"Access the underlying hash for advanced operations (read-only):
# Iterate through all integer values
env.session.ints.each do |key, value|
puts "#{key}: #{value}"
end
# Check what string keys exist
if env.session.strings.has_key?("username")
puts "User is logged in"
end
# Get all session data
puts "Total sessions: #{env.session.strings.size}"Store complex objects in sessions by implementing the StorableObject module. Perfect for user profiles, preferences, or any custom data structures.
# Define your class with JSON serialization
class User
include JSON::Serializable
include Kemal::Session::StorableObject # Add this after JSON::Serializable
property id : Int32
property name : String
property email : String
property preferences : Hash(String, String)
def initialize(@id : Int32, @name : String, @email : String)
@preferences = {} of String => String
end
endrequire "kemal"
require "kemal-session"
# Session Configuration
Kemal::Session.config.secret = "my-secret-key"
# Store user in session
post "/login" do |env|
user = User.new(123, "Alice", "alice@example.com")
user.preferences["theme"] = "dark"
user.preferences["language"] = "en"
env.session.object("current_user", user)
"Login successful!"
end
# Retrieve user from session
get "/profile" do |env|
user = env.session.object("current_user").as(User)
<<-HTML
<h1>Welcome, #{user.name}!</h1>
<p>Email: #{user.email}</p>
<p>Theme: #{user.preferences["theme"]?}</p>
HTML
end
# Update user preferences
post "/preferences" do |env|
user = env.session.object("current_user").as(User)
user.preferences["theme"] = env.params.body["theme"].as(String)
# Save updated user back to session
env.session.object("current_user", user)
"Preferences updated!"
endclass CartItem
include JSON::Serializable
include Kemal::Session::StorableObject
property id : String
property name : String
property price : Float64
property quantity : Int32
def initialize(@id : String, @name : String, @price : Float64, @quantity : Int32 = 1)
end
def total
price * quantity
end
end
class ShoppingCart
include JSON::Serializable
include Kemal::Session::StorableObject
property items : Array(CartItem)
def initialize
@items = [] of CartItem
end
def add_item(item : CartItem)
existing = items.find { |i| i.id == item.id }
if existing
existing.quantity += item.quantity
else
items << item
end
end
def total
items.sum(&.total)
end
def item_count
items.sum(&.quantity)
end
end
# Usage in routes
post "/cart/add" do |env|
cart = env.session.object?("cart").try(&.as(ShoppingCart)) || ShoppingCart.new
item = CartItem.new(
id: env.params.body["id"].as(String),
name: env.params.body["name"].as(String),
price: env.params.body["price"].to_f
)
cart.add_item(item)
env.session.object("cart", cart)
"Added to cart! Total: $#{cart.total} (#{cart.item_count} items)"
endCustomize session behavior to fit your application's needs:
Kemal::Session.config do |config|
config.cookie_name = "my_app_session" # Custom cookie name
config.secret = "your-super-secret-key" # π Always set this in production!
config.timeout = 2.hours # Session expires after 2 hours
config.gc_interval = 5.minutes # Clean expired sessions every 5 minutes
config.secure = true # Only send over HTTPS
config.domain = "example.com" # Scope to specific domain
endKemal::Session.config.cookie_name = "session_id"
Kemal::Session.config.secret = "my-secret-key"
Kemal::Session.config.timeout = 30.minutes| Option | Description | Default | Example |
|---|---|---|---|
timeout |
Session expires after this time since last activity | 1.hour |
2.hours, 30.minutes |
cookie_name |
Name of the session cookie | "kemal_sessid" |
"my_app_session" |
engine |
Storage backend for sessions | MemoryEngine |
FileEngine, RedisEngine |
gc_interval |
How often to clean expired sessions | 4.minutes |
10.minutes, 1.hour |
secret |
Secret key for signing session cookies | "" |
Generated secure string |
secure |
Send cookie only over HTTPS | false |
true for production |
domain |
Scope cookie to specific domain | nil |
"example.com" |
path |
Scope cookie to specific path | "/" |
"/app" |
samesite |
SameSite cookie policy | HTTP::Cookie::SameSite::Lax |
HTTP::Cookie::SameSite::Strict |
# Generate a random secret key
crystal eval 'require "random/secure"; puts Random::Secure.hex(64)'# Use environment variables in production
Kemal::Session.config.secret = ENV["SESSION_SECRET"]? || "fallback-for-development"Kemal::Session.config do |config|
config.secret = ENV["SESSION_SECRET"] # From environment
config.secure = true # HTTPS only
config.samesite = HTTP::Cookie::SameSite::Strict # CSRF protection
config.domain = "yourdomain.com" # Scope to your domain
config.timeout = 1.hour # Reasonable timeout
endKemal::Session.config do |config|
config.samesite = HTTP::Cookie::SameSite::Strict # Prevents CSRF attacks
config.secure = true # HTTPS only
config.domain = "example.com" # Limit to your domain
endChoose the right storage engine for your application's needs:
Perfect for development and single-server applications:
# Already the default, but you can configure it explicitly
Kemal::Session.config.engine = Kemal::Session::MemoryEngine.newPros: Fast, no setup required
Cons: Sessions lost on server restart, not suitable for multiple servers
Store sessions on disk for persistence across restarts:
Kemal::Session.config.engine = Kemal::Session::FileEngine.new({
:sessions_dir => "/var/lib/my_app/sessions/"
})Pros: Persists across restarts, simple setup
Cons: File I/O overhead, not suitable for multiple servers
For production applications, consider these external engines:
| Engine | Use Case | shard.yml |
|---|---|---|
| Redis | High performance, multiple servers | crystal-garage/kemal-session-redis-engine |
| PostgreSQL | Existing PostgreSQL infrastructure | mang/kemal-session-postgres |
| MySQL | Existing MySQL infrastructure | crisward/kemal-session-mysql |
| RethinkDB | Real-time applications | kingsleyh/kemal-session-rethinkdb |
# shard.yml
dependencies:
kemal-session:
github: kemalcr/kemal-session
kemal-session-redis-engine:
github: crystal-garage/kemal-session-redis-enginrequire "kemal"
require "kemal-session"
require "kemal-session-redis-engine"
Kemal::Session.config do |config|
config.cookie_name = "redis_test"
config.secret = "a_secret"
config.engine = Kemal::Session::RedisEngine.new(
"redis://localhost:6379/0?initial_pool_size=1&max_pool_size=10&checkout_timeout=10&retry_attempts=2&retry_delay=0.5&max_idle_pool_size=50",
key_prefix: "my_app:session:"
)
config.timeout = Time::Span.new(1, 0, 0)
end
get "/" do
puts "Hello World"
end
post "/sign_in" do |context|
context.session.int("see-it-works", 1)
end
Kemal.runCreate your own storage engine by implementing the required interface. Check the wiki for detailed instructions.
get "/logout" do |env|
env.session.destroy
redirect "/login"
endFor building admin interfaces, you can manage other users' sessions:
# Get specific session by ID
admin_session = Kemal::Session.get("session_id_here")
# Iterate through all active sessions
Kemal::Session.each do |session|
puts "Session: #{session.id}, Last Activity: #{session.last_access_time}"
end
# Get all sessions as an array
all_sessions = Kemal::Session.all
puts "Total active sessions: #{all_sessions.size}"
# Force logout a specific user
Kemal::Session.destroy("problematic_session_id")
# Emergency: Log out all users
Kemal::Session.destroy_allget "/admin/sessions" do |env|
# Always verify admin permissions first!
admin_user = env.session.object?("current_user").try(&.as(User))
halt env, status_code: 403, response: "Forbidden" unless admin_user.try(&.admin?)
sessions = Kemal::Session.all
# ... render admin interface
endKemal::Session.allandKemal::Session.eachload all sessions into memory- For high-traffic applications, consider pagination or streaming approaches
- The memory impact depends on your storage engine implementation
require "kemal"
require "kemal-session"
# Configure session for production
Kemal::Session.config do |config|
config.secret = ENV["SESSION_SECRET"]
config.secure = true if ENV["KEMAL_ENV"]? == "production"
config.timeout = 2.hours
config.samesite = HTTP::Cookie::SameSite::Strict
end
# Add CSRF protection
add_handler Kemal::Session::CSRF.new
# User model
class User
include JSON::Serializable
include Kemal::Session::StorableObject
property id : Int32
property username : String
property email : String
property admin : Bool
def initialize(@id : Int32, @username : String, @email : String, @admin : Bool = false)
end
end
# Login route
post "/login" do |env|
username = env.params.body["username"].as(String)
password = env.params.body["password"].as(String)
# Authenticate user (implement your logic)
if user = authenticate_user(username, password)
env.session.object("current_user", user)
env.session.string("login_time", Time.utc.to_s)
redirect "/dashboard"
else
env.session.string("error", "Invalid credentials")
redirect "/login"
end
end
# Protected route
get "/dashboard" do |env|
user = env.session.object?("current_user").try(&.as(User))
halt env, status_code: 401, response: "Please log in" unless user
"Welcome #{user.username}! You logged in at #{env.session.string?("login_time")}"
end
# Admin-only route
get "/admin" do |env|
user = env.session.object?("current_user").try(&.as(User))
halt env, status_code: 401, response: "Please log in" unless user
halt env, status_code: 403, response: "Admin required" unless user.admin
"Admin panel - manage users here"
end
Kemal.run# API endpoints with session authentication
get "/api/profile" do |env|
env.response.content_type = "application/json"
user = env.session.object?("current_user").try(&.as(User))
if user
user.to_json
else
env.response.status_code = 401
{"error" => "Authentication required"}.to_json
end
end- π Crystal Language Documentation
- π Kemal Framework
- π§ Creating Custom Engines
- π‘ Crystal Security Best Practices
We love contributions! Here's how you can help:
- π΄ Fork the repository
- π Create a feature branch (
git checkout -b my-new-feature) - βοΈ Make your changes and add tests
- β
Ensure all tests pass (
crystal spec) - β
Ensure consistent code formatting (
crystal tool format) - π Commit your changes (
git commit -am 'Add some feature') - π Push to the branch (
git push origin my-new-feature) - π― Create a Pull Request
Special thanks to:
- Thyra for the initial implementation
- The Crystal and Kemal communities for their support