kemal_identity
Version, currently 0.13.017 versions
- 0.13.0latestOct 8, 2026
- 0.12.2Sep 8, 2026
- 0.12.1Sep 6, 2026
- 0.12.0not indexedSep 6, 2026
- 0.11.1Sep 2, 2026
- 0.11.0not indexedSep 2, 2026
- 0.10.0not indexedSep 2, 2026
- 0.9.0not indexedSep 2, 2026
- 0.8.1not indexedSep 2, 2026
- 0.8.0not indexedSep 2, 2026
- 0.7.0Aug 28, 2026
- 0.6.0not indexedAug 28, 2026
- 0.5.0not indexedAug 28, 2026
- 0.4.0not indexedAug 28, 2026
- 0.3.0not indexedAug 28, 2026
- 0.2.0not indexedAug 28, 2026
- main branchAug 24, 2026
github.com/urunsiyabend/kemal-identity
Authentication for Crystal web applications — Kemal integration included. Server-side opaque sessions, password credentials, revocation.
Installation
# Add this to your shard.yml
dependencies:
kemal_identity:
github: urunsiyabend/kemal-identity
version: ~> 0.13.0Then run:
shards installshard.yml
- Crystal
>= 1.12.0- License
- MIT
- Author
- Siyabend Urun
- Targets
amebafrom lib/ameba/src/cli.crmigratefrom tools/migrate.cr
Dependencies
Runtime Dependencies
Development Dependencies
- pg*github: will/crystal-pgdev
- ameba*github: crystal-ameba/ameba, branch: masterdev
- sqlite3*github: crystal-lang/crystal-sqlite3dev
- spec-kemal*github: kemalcr/spec-kemaldev
README
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.
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This release
- Version
0.13.0- Tagged
- Oct 8, 2026
- Commit
3be3514a7c0e- Crystal
>= 1.12.0- Indexed
- yes
Dependents
Repository
github.com/urunsiyabend/kemal-identity
Metadata
- Created
- Aug 25, 2026
- Updated
- Nov 8, 2026
- Synced
- Nov 8, 2026
- Versions
- 17