kemal-identity v0.13.0
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.13.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.13.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"]),
# Counters in auth_rate_limits, shared by every process. FixedWindowRateLimiter is the
# in-memory equivalent, for a deployment that genuinely runs one process.
rate_limiter: KemalIdentity::Postgres::FixedWindowRateLimiter.new(
db,
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!
# Likewise for a configuration that leaves login or MFA protections off. Opt-in.
KemalIdentity.validate_production!
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
- Call
KemalIdentity.validate_production!at boot. It raises oneConfigurationErrornaming every login or MFA protection the configuration leaves off — an unthrottled or fail-open login, a process-local limiter, no bound on guessing a second factor, an unthrottled recovery path, an insecure cookie — with the fix for each. A gap the deployment has decided to live with is accepted by name:KemalIdentity.validate_production!(accept: [KemalIdentity::ProductionGap::ProcessLocalRateLimiter]). It is not run for you, so upgrading never stops a working deployment from booting. NullRateLimiteris the default and permits every attempt.FixedWindowRateLimiteris process-local, so N processes allow N times the limit.KemalIdentity::Postgres::FixedWindowRateLimitershares one counter per key across processes and hosts (SQLite::FixedWindowRateLimiteracross processes on one host). Both need thecreate_auth_rate_limitsmigration, and the sweeper reclaims elapsed windows.Authz::Permission.new("payouts.change", max_age: 5.minutes)declares recency on the action, asminimum_assurancedeclares strength; a stale principal is refused with step-up.api.revoke_family(ids, account_id)revokes several of an account's API tokens in one atomic step, for a rotation abandoned or a set of keys leaked together.- 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
- 7
- 1
- 0
- 1
- 6
- about 1 month ago
- August 24, 2026
MIT License
Thu, 08 Oct 2026 11:43:56 GMT