github.com/urunsiyabend/kemal-identity

Authentication for Crystal web applications — Kemal integration included. Server-side opaque sessions, password credentials, revocation.

6 stars
1 dependent
License: MIT

Nothing has been indexed for 0.11.0 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.

Installation

# Add this to your shard.yml
dependencies:
  kemal_identity:
    github: urunsiyabend/kemal-identity
    version: ~> 0.11.0

Then run:

shards install

shard.yml

No shard.yml has been indexed for 0.11.0. You can read it on the repository.

Dependencies

Unknown: the shard.yml for this version has not been read yet.

README

This README is the one indexed from the repository at its latest ref, not from the tag for this version.

Kemal Identity

CI License: MIT

Authentication and identity primitives for Crystal applications, with first-class Kemal integration.

Kemal Identity provides revocable server-side sessions, password authentication, CSRF protection, bearer credentials, MFA, federated sign-in, and optional role-based authorization. The core is framework-independent; the Kemal adapter adds request context and middleware.

Features

  • Opaque, server-side sessions with idle and absolute expiry
  • Password authentication with bcrypt and lazy hash migration
  • Secure cookie defaults and session fixation protection
  • CSRF protection for authenticated and anonymous forms
  • Password reset, email confirmation, and remember-me flows
  • Opaque API tokens and opt-in JWT validation
  • TOTP second factors and recovery codes
  • OpenID Connect sign-in
  • Optional RBAC with tenant-aware assignments
  • PostgreSQL and SQLite adapters
  • Structured security events and expired-record sweeping

Kemal Identity does not provide registration screens, profile management, application-specific user models, or an OAuth2 authorization server. Applications own those concerns and integrate them through repository and service contracts.

Requirements

  • Crystal 1.12 or newer
  • Kemal 1.10 or newer; Kemal 1.13 or newer is recommended

Crystal 1.21 or newer is recommended when using HashingExecutor, which isolates CPU-intensive password hashing from request execution. The project is currently pre-1.0, so minor releases may contain breaking API changes; consult the changelog before upgrading.

Installation

Add the shard to your application's shard.yml:

dependencies:
  kemal_identity:
    github: urunsiyabend/kemal-identity
    version: ~> 0.9.0

Then install dependencies:

shards install

Database drivers are intentionally not transitive dependencies. Add the driver used by your application:

dependencies:
  kemal_identity:
    github: urunsiyabend/kemal-identity
    version: ~> 0.9.0
  pg:
    github: will/crystal-pg

Use sqlite3 from crystal-lang/crystal-sqlite3 instead of pg for SQLite.

Quick start

The following example uses the reference PostgreSQL account and session repositories. Apply the SQL files under migrations/postgres before starting the application.

require "kemal"
require "kemal_identity/kemal"
require "kemal_identity/postgres"

db = DB.open(ENV["DATABASE_URL"])

KemalIdentity.configure(
  accounts: KemalIdentity::Postgres::AccountRepository.new(db),
  sessions: KemalIdentity::Postgres::SessionRepository.new(db),
  csrf: KemalIdentity::CSRFConfig.new(secret: ENV["CSRF_SECRET"]),
  rate_limiter: KemalIdentity::FixedWindowRateLimiter.new(
    limit: 10,
    window: 5.minutes
  ),
  hasher: KemalIdentity::Passwords::HashingExecutor.new(
    KemalIdentity::Passwords::BcryptHasher.new(cost: 12),
    size: 2
  )
)

# Order matters: error handling wraps authentication, and CSRF runs after it.
use KemalIdentity::Kemal::ErrorHandler.new(login_path: "/login")
use KemalIdentity::Kemal::AuthenticationHandler.new
use KemalIdentity::Kemal::CSRFHandler.new

get "/dashboard" do |env|
  principal = env.auth.require!
  "Signed in as #{principal.subject}"
end

# Every wrong arrangement of those three lines compiles. This says so at boot instead.
KemalIdentity::Kemal.validate_middleware_order!

Kemal.run

CSRF_SECRET must contain at least 32 bytes of cryptographically random material. Do not register identity middleware at position 0; it must remain behind Kemal's initialization handler.

Sign in and sign out

Kemal Identity exposes services rather than imposing routes, templates, or response formats:

post "/login" do |env|
  result = KemalIdentity.app.passwords.authenticate(
    login: env.params.body["email"],
    password: env.params.body["password"],
    tenant_id: nil,
    ip: env.request.remote_address.to_s
  )

  case result
  in KemalIdentity::Authenticated
    env.auth.start!(result.principal)
    env.redirect "/dashboard"
  in KemalIdentity::Failed, KemalIdentity::Anonymous
    # Keep the response identical for every failure reason.
    env.response.status_code = 401
    "Invalid email or password"
  end
end

post "/logout" do |env|
  env.auth.logout!
  env.redirect "/"
end

ip: keys the source-address half of the login rate limit, so it has to be the client's address. remote_address is that only when Kemal faces the internet directly. Behind nginx, an ALB, or a CDN it is the proxy, and every login in the deployment then shares one address key — the first attacker to fill the window locks out every user. Behind a proxy, resolve the client address from X-Forwarded-For counting from the right by the number of proxies you operate, and treat the leftmost entries as client-supplied. Passing the whole header, or its first value, hands an attacker a fresh allowance per request.

Call env.auth.require! inside individual routes, or guard an entire path subtree:

use KemalIdentity::Kemal::PathGuard.new(prefix: "/admin")
use KemalIdentity::Kemal::PathGuard.new(prefix: "/account/security", within: 5.minutes)

require! produces a 401 response through ErrorHandler. Fresh-authentication and authorization failures produce 403 responses. env.auth.principal?, authenticated?, can?, and authorize! are available for optional rendering and authorization checks.

CSRF-protected forms

Render the request-bound token in every form that performs an unsafe request, including the login form:

<input type="hidden" name="_csrf" value="<%= env.auth.csrf_token %>">

API clients may send the same value in the X-CSRF-Token header. Requests authenticated only with a bearer token are exempt; requests that also present a session cookie remain protected.

Storage and migrations

Require only the adapter your application uses:

require "kemal_identity/postgres"
# or
require "kemal_identity/sqlite"

Migration sets are published separately for each database:

Copy or reference these SQL files from your application's migration tooling. The library never changes schema during application startup.

Postgres::AccountRepository and SQLite::AccountRepository use the included auth_accounts schema as reference implementations. Existing applications can implement KemalIdentity::Accounts::Repository over their own users table. Session repositories accept an alternate account table name:

sessions = KemalIdentity::Postgres::SessionRepository.new(
  db,
  accounts_table: "users"
)

For SQLite, enable write-ahead logging and a busy timeout:

db = DB.open("sqlite3://./identity.db?journal_mode=wal&busy_timeout=5000")

PostgreSQL is the recommended adapter for multi-process, write-heavy deployments.

Optional capabilities

Optional services are disabled until all of their required dependencies are configured:

CapabilityConfiguration
Password reset and email confirmationaction_tokens: and notifier:
Remember meremember_tokens:
Opaque API tokensapi_tokens:
JWT validationjwt:
TOTP MFAmfa_factors:, mfa_secret_key:, and mfa_issuer:
Authorizationauthorizer:

This fail-closed wiring prevents partially configured security features from appearing to work. See the architecture, security model, and data model for complete integration details.

Production notes

  • NullRateLimiter is the default and permits every attempt. Configure a shared limiter in multi-process deployments; FixedWindowRateLimiter is process-local.
  • Behind a reverse proxy, the ip: you pass to passwords.authenticate must be the resolved client address, not request.remote_address — see the sign-in example above.
  • The default __Host-kemal_identity cookie is Secure, host-only, HTTP-only, and SameSite=Lax. To share sessions across subdomains, use a non-__Host- name and explicitly set a domain.
  • For local HTTP development only, use a non-prefixed cookie name with secure: false and allow_insecure: true.
  • Principal#subject is a String; convert it to your application's identifier type at the boundary.
  • JWT support is off by default. If early revocation is required, configure a revocation store or prefer opaque API tokens.
  • Run cleanup from one scheduler or cron job in multi-process deployments:
KemalIdentity::Sweeper.new(KemalIdentity.app).sweep

Expired and revoked credentials are rejected during reads; sweeping only reclaims storage.

Logging

Security events use Crystal's Log infrastructure under the kemal_identity.* namespace:

Log.setup do |config|
  backend = Log::IOBackend.new
  config.bind "kemal_identity.*", :info, backend
end

Route these events to your audit pipeline and alert on high-signal events such as replay detection, MFA recovery-code use, and repeated authentication failures. Secrets, raw tokens, and password digests are not included in emitted events.

Development

shards install
crystal tool format --check
shards build ameba
bin/ameba
crystal spec spec/unit spec/security spec/integration/sqlite_spec.cr

PostgreSQL integration specs additionally require DATABASE_URL and the PostgreSQL migrations. See testing for the full test matrix.

Documentation

License

Kemal Identity is available under the MIT License.