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.
- 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.
- 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.
Add the shard to your application's shard.yml:
dependencies:
kemal_identity:
github: urunsiyabend/kemal-identity
version: ~> 0.9.0Then install dependencies:
shards installDatabase 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-pgUse sqlite3 from crystal-lang/crystal-sqlite3 instead of pg for SQLite.
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.runCSRF_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.
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 "/"
endip: 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.
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.
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 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.
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).sweepExpired and revoked credentials are rejected during reads; sweeping only reclaims storage.
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
endRoute 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.
shards install
crystal tool format --check
shards build ameba
bin/ameba
crystal spec spec/unit spec/security spec/integration/sqlite_spec.crPostgreSQL integration specs additionally require DATABASE_URL and the PostgreSQL migrations.
See testing for the full test matrix.
- 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
Kemal Identity is available under the MIT License.