# Kemal Auth

> User authentication and session management in Kemal, following established project patterns.

- Skill: `kemalcr/kemal-auth` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kemalcr/kemal-auth`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kemalcr/kemal-auth/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: kemalcr (https://skillmd.com/u/kemalcr)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kemalcr/kemal-auth

---


# Kemal Authentication & Sessions

This skill provides expert guidance on implementing user authentication and session management in Kemal, strictly following patterns from [`kemal-by-example/ecommerce`](https://github.com/sdogruyol/kemal-by-example/tree/master/ecommerce) and [`kemal-by-example/oauth-login`](https://github.com/sdogruyol/kemal-by-example/tree/master/oauth-login).

## Core Mandates

- **Dependencies:** Always `require "kemal-session"`.
- **Session Configuration:** Use `Kemal::Session.config` to set `secret`, `cookie_name`, and `gc_interval`:

  ```crystal
  Kemal::Session.config do |config|
    config.secret = ENV["KEMAL_SESSION_SECRET"]? || raise "KEMAL_SESSION_SECRET not set"
    config.cookie_name = "your_app_session"
    config.gc_interval = 2.minutes
  end
  ```

- **Auth Helpers:** Implement auth logic in a module (e.g., `Ecommerce::Auth`):
  - `current_user(env)`: Use `env.session.bigint?("user_id")` to retrieve the ID and find the user
  - `require_user(env)`: Call `current_user(env)` and redirect to `/login` if `nil`
  - `sign_in(env, user)`: Set `env.session.bigint("user_id", user.id || raise "User ID required")`
  - `sign_out(env)`: Call `env.session.destroy`

- **Password Hashing:** Use `Crypto::Bcrypt::Password` for securely storing and authenticating passwords.
- **Error Handling:** Use specific exception types (like `DB::Error`) in auth helpers.

  ```crystal
  def current_user(env) : User?
    user_id = env.session.bigint?("user_id")
    return unless user_id
    User.find(user_id)
  rescue DB::Error
    nil
  end
  ```

## Patterns from Source Code

### Auth Helper Module (ecommerce/src/helpers/auth.cr)

```crystal
module Ecommerce
  module Auth
    extend self

    def current_user(env) : User?
      user_id = env.session.bigint?("user_id")
      return unless user_id
      User.find(user_id)
    rescue DB::Error
      nil
    end

    def require_user(env) : User?
      user = current_user(env)
      return user if user
      env.redirect "/login"
      nil
    end

    def sign_in(env, user : User)
      user_id = user.id || raise ArgumentError.new("Cannot sign in user without ID")
      env.session.bigint("user_id", user_id)
    end

    def sign_out(env)
      env.session.destroy
    end
  end
end
```

### Session Secret (Explicit Configuration)

From ecommerce and oauth-login:

```crystal
Kemal::Session.config do |config|
  config.secret = ENV["KEMAL_SESSION_SECRET"]? || raise "KEMAL_SESSION_SECRET not set"
  config.cookie_name = "ecommerce_session_id"
  config.gc_interval = 2.minutes
end
```

### User Model with Bcrypt

From ecommerce/src/models/user.cr:

```crystal
require "crypto/bcrypt"

class User
  include DB::Serializable

  getter id : Int64?
  getter name : String
  getter email : String
  getter password_hash : String
  getter created_at : String
  getter updated_at : String

  def self.create(name : String, email : String, password : String) : User
    now = Time.utc.to_s
    normalized_email = normalize_email(email)
    password_hash = Crypto::Bcrypt::Password.create(password, cost: 12).to_s
    # ... insert and return user
  end

  def self.authenticate(email : String, password : String) : User?
    user = find_by_email(normalize_email(email))
    return unless user
    return user if Crypto::Bcrypt::Password.new(user.password_hash).verify(password)
    nil
  end

  def self.normalize_email(value : String) : String
    value.strip.downcase
  end
end
```

### Login Route Pattern

```crystal
post "/login" do |env|
  email = env.params.body["email"]?.try(&.strip) || ""
  password = env.params.body["password"]?.try(&.strip) || ""
  user = User.authenticate(email, password)

  if user
    Ecommerce::Auth.sign_in(env, user)
    env.redirect "/products"
  else
    current_user = nil
    cart_count = 0_i64
    error_message = "Invalid email or password."
    env.response.status = :unprocessable_entity
    render "src/views/auth/login.ecr", "src/views/layouts/application.ecr"
  end
end

post "/logout" do |env|
  Ecommerce::Auth.sign_out(env)
  env.redirect "/products"
end
```

## Best Practices

- **Local Variables for Errors:** Pass error messages as local variables directly to the `render` macro (e.g., `error_message = "Invalid email or password."`).
- **Security:** Use `POST` (never `GET`) for login and logout so state changes are not triggerable via simple links — but note that using `POST` alone does **not** prevent CSRF. Add real CSRF protection: validate a per-session CSRF token on state-changing requests (e.g. the [`kemal-csrf`](https://github.com/kemalcr/kemal-csrf) handler) and set session cookies with `SameSite`. Regenerate the session on login to prevent session fixation.
- **Session Safe Access:** Use `env.session.bigint?("user_id")` or similar to safely retrieve session data.
- **Model Methods:** Implement `User.authenticate(email, password)` and `User.find_by_email(email)` in the model.

## When to Use

- When implementing signup, login, or logout features.
- When protecting specific routes from unauthorized access.
- When managing user-specific state across requests.

