Installation

# Add this to your shard.yml
dependencies:
  kemal-session:
    github: kemalcr/kemal-session
    version: ~> 1.6.0

Then run:

shards install

shard.yml

Crystal
>= 1.0.0
Author
Serdar Dogruyol <dogruyolserdar@gmail.com>

Dependencies

Development Dependencies

  • kemal~> 1.11.0github: kemalcr/kemaldev

README

# kemal-session

[![Build Status](https://github.com/kemalcr/kemal-session/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/kemal-session/actions/workflows/ci.yml)

> πŸš€ **Powerful session management for Kemal web applications**

Add secure, persistent session support to your [Kemal](https://github.com/sdogruyol/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`:

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

Then run:
```bash
shards install
```

## πŸš€ Quick Start

### 1. Basic Session Usage (User Login / Logout)

```crystal
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

```crystal
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

```crystal
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
```

### Advanced CSRF Configuration

```crystal
# 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](https://htmx.org),
as you can change the CSRF token with each full page load while keeping it the same for partial operations.

### CSRF for API Endpoints

```crystal
# 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

```crystal
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

```crystal
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

```crystal
# 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):

```crystal
# 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

```crystal
# 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

```crystal
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

```crystal
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

```crystal
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

```crystal
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

```bash
# Generate a random secret key
crystal eval 'require "random/secure"; puts Random::Secure.hex(64)'
```

```crystal
# Use environment variables in production
Kemal::Session.config.secret = ENV["SESSION_SECRET"]? || "fallback-for-development"
```

#### 2. Production Security Settings

```crystal
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

```crystal
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:

```crystal
# 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:

```crystal
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](https://github.com/crystal-garage/kemal-session-redis-engine)** | High performance, multiple servers | `crystal-garage/kemal-session-redis-engine` |
| **[PostgreSQL](https://github.com/mang/kemal-session-postgres)** | Existing PostgreSQL infrastructure | `mang/kemal-session-postgres` |
| **[MySQL](https://github.com/crisward/kemal-session-mysql)** | Existing MySQL infrastructure | `crisward/kemal-session-mysql` |
| **[RethinkDB](https://github.com/kingsleyh/kemal-session-rethinkdb)** | Real-time applications | `kingsleyh/kemal-session-rethinkdb` |

### Redis Engine Example

```yaml
# shard.yml
dependencies:
  kemal-session:
    github: kemalcr/kemal-session
  kemal-session-redis-engine:
    github: crystal-garage/kemal-session-redis-engin
```

```crystal
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](https://github.com/kemalcr/kemal-session/wiki/Creating-your-session-engine) for detailed instructions.

## πŸšͺ Session Management

### πŸšͺ User Logout

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

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

For building admin interfaces, you can manage other users' sessions:

```crystal
# 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:

```crystal
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

```crystal
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

```crystal
# 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

- πŸ“– [Crystal Language Documentation](https://crystal-lang.org/docs/)
- 🌐 [Kemal Framework](https://kemalcr.com/)
- πŸ”§ [Creating Custom Engines](https://github.com/kemalcr/kemal-session/wiki/Creating-your-session-engine)
- πŸ’‘ [Crystal Security Best Practices](https://crystal-lang.org/reference/guides/security.html)

## 🀝 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](https://github.com/Thyra) for the initial implementation
- The Crystal and Kemal communities for their support