kemal-identity v0.12.1
Kemal Identity
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:
| Capability | Configuration |
|---|---|
| Password reset and email confirmation | action_tokens: and notifier: |
| Remember me | remember_tokens: |
| Opaque API tokens | api_tokens: |
| JWT validation | jwt: |
| TOTP MFA | mfa_factors:, mfa_secret_key:, and mfa_issuer: |
| Authorization | authorizer: |
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
NullRateLimiteris the default and permits every attempt. Configure a shared limiter in multi-process deployments;FixedWindowRateLimiteris process-local.- Behind a reverse proxy, the
ip:you pass topasswords.authenticatemust be the resolved client address, notrequest.remote_address— see the sign-in example above. - The default
__Host-kemal_identitycookie isSecure, host-only, HTTP-only, andSameSite=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: falseandallow_insecure: true. Principal#subjectis aString; 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
- Examples — eight runnable applications, each a single file: a browser site, a JSON API with scoped tokens, a second factor with recovery codes, per-object authorization, a credential this shard does not ship, several JWT issuers, a mixed monolith, and a workload identity
- Scope
- Architecture
- Security model
- Data model
- Kemal integration
- Testing
- Changelog
License
Kemal Identity is available under the MIT License.
kemal-identity
- 6
- 0
- 0
- 1
- 6
- 8 days ago
- August 24, 2026
MIT License
Tue, 08 Sep 2026 08:01:20 GMT