π 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-session
Then run:
shards install
require "kemal"
require "kemal-session"
# Session Configuration
Kemal::Session.config.secret = "my-secret-key"
# Store data in session
get "/login" do |env|
env.session.string("username", "alice")
env.session.int("user_id", 123)
"Welcome! You're now logged in."
end
# Retrieve data from session
get "/profile" do |env|
username = env.session.string("username")
user_id = env.session.int("user_id")
"Hello #{username}! Your ID is #{user_id}"
end
# Optional values (returns nil if not found)
get "/dashboard" do |env|
last_visit = env.session.string?("last_visit")
message = last_visit ? "Welcome back! Last visit: #{last_visit}" : "First time here!"
env.session.string("last_visit", Time.utc.to_s)
message
end
Kemal.run
require "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.run
Protect 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.run
# 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
)
# 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_handler
Kemal 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
end
require "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", "[email protected]")
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!"
end
class 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)"
end
Customize 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
end
Kemal::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 | nil |
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
end
Kemal::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
end
Choose 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.new
Pros: 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 | Setup |
---|---|---|
Redis | High performance, multiple servers | shard.yml: kemal-session-redis |
PostgreSQL | Existing PostgreSQL infrastructure | shard.yml: kemal-session-postgres |
MySQL | Existing MySQL infrastructure | shard.yml: kemal-session-mysql |
RethinkDB | Real-time applications | shard.yml: kemal-session-rethinkdb |
# shard.yml
dependencies:
kemal-session:
github: kemalcr/kemal-session
kemal-session-redis:
github: neovintage/kemal-session-redis
require "kemal"
require "kemal-session"
require "kemal-session-redis"
Kemal::Session.config.engine = Kemal::Session::RedisEngine.new(
host: "localhost",
port: 6379,
password: ENV["REDIS_PASSWORD"]?,
database: 0
)
Create your own storage engine by implementing the required interface. Check the wiki for detailed instructions.
get "/logout" do |env|
env.session.destroy
redirect "/login"
end
For 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_all
get "/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
end
Kemal::Session.all
andKemal::Session.each
load 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
) - π 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