kemal-session v1.6.0

Powerful Session Management for Kemal applications

kemal-session

Build Status

πŸš€ 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.

✨ Why kemal-session?

  • 🎯 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

πŸ“¦ Installation

Add kemal-session to your shard.yml:

dependencies:
  kemal-session:
    github: kemalcr/kemal-session

Then run:

shards install

πŸš€ Quick Start

1. Basic Session Usage (User Login / Logout)

require "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.run

2. Real-World Example: Shopping Cart

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

πŸ›‘οΈ CSRF Protection

Protect your application from Cross-Site Request Forgery attacks with built-in CSRF middleware.

Basic CSRF Setup

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

How the token is created

The 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 session

env.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.

JavaScript clients and JSON APIs

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
end

Call 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
end

Scope 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.

Advanced CSRF Configuration

# 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
)

CSRF Token per request or per session?

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.

CSRF for API Endpoints

# 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

πŸ’¬ Flash Messages

Flash 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.

Basic Usage

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

API

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.

Example: Form with Success/Error

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
end

πŸ“Š Supported Data Types

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)

πŸ” Reading Values

# 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"

πŸ—‚οΈ Working with Collections

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}"

⚠️ Important: Only use hash access for reading. Never modify values directly through these hashes, as changes won't be persisted!

🎯 Custom Objects (StorableObject)

Store complex objects in sessions by implementing the StorableObject module. Perfect for user profiles, preferences, or any custom data structures.

Creating a Storable Object

# 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

Using Storable Objects

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", "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!"
end

Complex Example: Shopping Cart with Items

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

βš™οΈ Configuration

Customize session behavior to fit your application's needs:

Quick Configuration

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

One-line Configuration

Kemal::Session.config.cookie_name = "session_id"
Kemal::Session.config.secret = "my-secret-key"
Kemal::Session.config.timeout = 30.minutes

πŸ“‹ Configuration Options

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

πŸ” Security Best Practices

1. Generate a Secure Secret

# 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"

2. Production Security Settings

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

3. Cookie Security

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

πŸ—„οΈ Storage Engines

Choose the right storage engine for your application's needs:

Memory Engine (Default)

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

File Engine

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

Production-Ready Engines

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

Redis Engine Example

# shard.yml
dependencies:
  kemal-session:
    github: kemalcr/kemal-session
  kemal-session-redis-engine:
    github: crystal-garage/kemal-session-redis-engin
require "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.run

Custom Engine

Create your own storage engine by implementing the required interface. Check the wiki for detailed instructions.

πŸšͺ Session Management

πŸšͺ User Logout

get "/logout" do |env|
  env.session.destroy
  redirect "/login"
end

πŸ‘¨β€πŸ’Ό Administrative Session Management

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

⚠️ Security Warning: Administrative session functions access ALL user sessions. Use with extreme caution and proper authorization checks:

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

πŸ—‘οΈ Memory Considerations

  • Kemal::Session.all and Kemal::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

πŸ† Production Examples

Complete Authentication System

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 with Session-based Auth

# 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

πŸ“š Helpful Resources

🀝 Contributing

We love contributions! Here's how you can help:

  1. 🍴 Fork the repository
  2. 🌟 Create a feature branch (git checkout -b my-new-feature)
  3. ✍️ Make your changes and add tests
  4. βœ… Ensure all tests pass (crystal spec)
  5. βœ… Ensure consistent code formatting (crystal tool format)
  6. πŸ“ Commit your changes (git commit -am 'Add some feature')
  7. πŸš€ Push to the branch (git push origin my-new-feature)
  8. 🎯 Create a Pull Request

πŸ™ Acknowledgments

Special thanks to:

  • Thyra for the initial implementation
  • The Crystal and Kemal communities for their support
Repository

kemal-session

Owner
Statistic
  • 66
  • 35
  • 2
  • 88
  • 1
  • 12 days ago
  • November 20, 2016
License

MIT License

Links
Synced at

Sun, 30 Aug 2026 17:16:12 GMT

Languages