Note
This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
We do these things not because they're easy, but because we thought they'd be easy.
A lightweight, self-hosted identity & SSO / IpD portal
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, Proxmox? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
Clinch runs as a single Docker container, using SQLite as the database, the job queue (Solid Queue) and the shared cache (Solid Cache). The webserver, Puma, runs the job queue in-process, avoiding the need for another container.
Clinch sits in a sweet spot between two excellent open-source identity solutions:
Authelia is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
Authentik is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
Clinch offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
- First-run wizard - Initial user automatically becomes admin
- Admin dashboard - Create, disable, and delete users
- Group-based organization - Organize users into groups (admin, family, friends, etc.)
- User statuses - Active, disabled, or pending invitation
- WebAuthn/Passkeys - Modern passwordless authentication using FIDO2 standards
- Password authentication - Secure bcrypt-based password storage
- TOTP 2FA - Optional time-based one-time passwords with QR code setup
- Backup codes - 10 single-use recovery codes per user
- Configurable 2FA enforcement - Admins can require TOTP for specific users
Apps that speak OIDC use the OIDC flow. Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
Standard OAuth2/OIDC provider with endpoints:
/.well-known/openid-configuration- Discovery endpoint/authorize- Authorization endpoint with PKCE support/token- Token endpoint (authorization_code and refresh_token grants)/userinfo- User info endpoint/revoke- Token revocation endpoint (RFC 7009)
Features:
- Refresh tokens - Long-lived tokens (30 days default) with automatic rotation and revocation
- Token family tracking - Advanced security detects token replay attacks and revokes compromised token families
- Configurable token expiry - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
- Token security - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), automatic cleanup of expired tokens
- Pairwise subject identifiers - Each user gets a unique, stable
subclaim per application for enhanced privacy
ID Token Claims (JWT with RS256 signature):
| Claim | Description | Notes |
|---|---|---|
| Standard Claims | ||
iss |
Issuer (Clinch URL) | From CLINCH_HOST |
sub |
Subject (user identifier) | Pairwise SID - unique per app |
aud |
Audience | OAuth client_id |
exp |
Expiration timestamp | Configurable TTL |
iat |
Issued-at timestamp | Token creation time |
email |
User email | |
email_verified |
Email verification | Always true |
preferred_username |
Username/email | Fallback to email |
name |
Display name | User's name or email |
nonce |
Random value | From auth request (prevents replay) |
| Security Claims | ||
at_hash |
Access token hash | SHA-256 hash of access_token (OIDC Core Β§3.1.3.6) |
auth_time |
Authentication time | Unix timestamp of when user logged in (OIDC Core Β§2) |
acr |
Auth context class | "1" = password, "2" = 2FA/passkey (OIDC Core Β§2) |
azp |
Authorized party | OAuth client_id (OIDC Core Β§2) |
| Custom Claims | ||
groups |
User's groups | Array of group names |
| custom | Arbitrary key-values | From groups, users, or app-specific config |
Authentication Context Class Reference (acr):
"1"- Something you know (password only)"2"- Two-factor or phishing-resistant (TOTP, backup codes, WebAuthn/passkey)
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
Works with reverse proxies (Caddy, Traefik, Nginx):
- Proxy sends every request to
/api/verify - Response handling:
- 200 OK β Proxy injects headers (
Remote-User,Remote-Groups,Remote-Email) and forwards to app - Any other status β Proxy returns that response directly to client (typically 302 redirect to login page)
- 200 OK β Proxy injects headers (
Note: ForwardAuth requires applications to run on the same domain as Clinch (e.g., app.yourdomain.com with Clinch at auth.yourdomain.com) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
Send emails for:
- Invitation links (one-time token, 7-day expiry)
- Password reset links (one-time token, 1-hour expiry)
- Device tracking - See all active sessions with device names and IPs
- Remember me - Long-lived sessions (30 days) for trusted devices
- Session revocation - Users and admins can revoke individual sessions
Clinch uses groups to control which users can access which applications:
- Create groups - Organize users into logical groups (readers, editors, family, developers, etc.)
- Assign groups to applications - Each app defines which groups are allowed to access it
- Example: Kavita app allows the "readers" group β only users in the "readers" group can sign in
- If no groups are assigned to an app β all active users can access it
- Automatic enforcement - Access checks happen automatically:
- During OIDC authorization flow (before consent)
- During ForwardAuth verification (before proxying requests)
- Users not in allowed groups receive a "You do not have permission" error
- OIDC tokens include group membership - ID tokens contain a
groupsclaim with all user's groups - Custom claims - Add arbitrary key-value pairs to tokens via groups and users
- Group claims apply to all members (e.g.,
{"role": "viewer"}) - User claims override group claims for fine-grained control
- Perfect for app-specific authorization (e.g., admin vs. read-only roles)
- Group claims apply to all members (e.g.,
Custom claims from groups and users are merged into OIDC ID tokens with the following precedence:
- Default OIDC claims - Standard claims (
iss,sub,aud,exp,email, etc.) - Standard Clinch claims -
groupsarray (list of user's group names) - Group custom claims - Merged in order; later groups override earlier ones
- User custom claims - Override all group claims
- Application-specific claims - Highest priority; override all other claims
Example:
- Group "readers" has
{"role": "viewer", "max_items": 10} - Group "premium" has
{"role": "subscriber", "max_items": 100} - User (in both groups) has
{"max_items": 500} - Result:
{"role": "subscriber", "max_items": 500}(user overrides max_items, premium overrides role)
Configure different claims for different applications on a per-user basis:
- Per-app customization - Each application can have unique claims for each user
- Highest precedence - App-specific claims override group and user global claims
- Use case - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf)
- Admin UI - Configure via Admin β Users β Edit User β App-Specific Claim Overrides
Example:
- User Alice, global claims:
{"theme": "dark"} - Kavita app-specific:
{"kavita_groups": ["admin"]} - Audiobookshelf app-specific:
{"abs_groups": ["user"]} - Result: Kavita receives
{"theme": "dark", "kavita_groups": ["admin"]}, Audiobookshelf receives{"theme": "dark", "abs_groups": ["user"]}
User
- Email address (unique, normalized to lowercase)
- Password (bcrypt hashed)
- Admin flag
- TOTP secret and backup codes (encrypted)
- TOTP enforcement flag
- Status (active, disabled, pending_invitation)
- Custom claims (JSON) - arbitrary key-value pairs added to OIDC tokens
- Token generation for invitations, password resets, and magic logins
Group
- Name (unique, normalized to lowercase)
- Description
- Custom claims (JSON) - shared claims for all members (merged with user claims)
- Many-to-many with Users and Applications
Session
- User reference
- IP address and user agent
- Device name (parsed from user agent)
- Remember me flag
- Expiry (24 hours or 30 days if remembered)
- Last activity timestamp
Application
- Name and slug (URL-safe identifier)
- Type (oidc or forward_auth)
- Client ID and secret (for OIDC apps)
- Redirect URIs (for OIDC apps)
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl)
- Metadata (flexible JSON storage)
- Active flag
- Many-to-many with Groups (allowlist)
OIDC Tokens
- Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
- Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable)
- Refresh tokens (opaque, HMAC-SHA256 hashed, configurable expiry 1-90 days, single-use with rotation)
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
- Client redirects user to
/authorizewith client_id, redirect_uri, scope (optional PKCE) - User authenticates with Clinch (username/password + optional TOTP)
- Access control check: Is user in an allowed group for this app?
- If allowed, generate authorization code and redirect to client
- Client exchanges code at
/tokenfor ID token, access token, and refresh token - Client uses access token to fetch fresh user info from
/userinfo - When access token expires, client uses refresh token to get new tokens (no re-authentication)
- User requests protected resource at
https://app.example.com/dashboard - Reverse proxy sends request to Clinch at
/api/verify - Clinch checks for valid session cookie
- If valid session and user allowed:
- Return 200 with
Remote-User,Remote-Groups,Remote-Emailheaders - Proxy forwards request to app with injected headers
- Return 200 with
- If no session or not allowed:
- Return 401/403
- Proxy redirects to Clinch login page
- After login, redirect back to original URL
After successful login, you may notice an fa_token query parameter appended to redirect URLs (e.g., https://app.example.com/dashboard?fa_token=...). This solves a timing issue:
The Problem:
- User signs in β session cookie is set
- Browser gets redirected to protected resource
- Browser may not have processed the
Set-Cookieheader yet - Reverse proxy checks
/api/verifyβ no cookie yet β auth fails β
The Solution:
- A one-time token (
fa_token) is added to the redirect URL as a query parameter /api/verifychecks for this token first, before checking cookies- Token is cached for 60 seconds and deleted immediately after use
- This gives the browser's cookie handling time to catch up
This is transparent to end users and requires no configuration.
- Ruby 3.3+
- SQLite 3.8+
- SMTP server (for sending emails)
# Install dependencies
bundle install
# Setup database
bin/rails db:setup
# Run migrations
bin/rails db:migrate
# Start server
bin/devCreate a docker-compose.yml file:
services:
clinch:
image: ghcr.io/dkam/clinch:latest
ports:
- "127.0.0.1:3000:3000" # Bind to localhost only (reverse proxy on same host)
# Use "3000:3000" if reverse proxy is in Docker network or different host
environment:
# Rails Configuration
RAILS_ENV: production
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
# Application Configuration
CLINCH_HOST: ${CLINCH_HOST}
CLINCH_FROM_EMAIL: ${CLINCH_FROM_EMAIL:-noreply@example.com}
# SMTP Configuration
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT}
SMTP_DOMAIN: ${SMTP_DOMAIN}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-plain}
SMTP_ENABLE_STARTTLS: ${SMTP_ENABLE_STARTTLS:-true}
# OIDC Configuration (optional - generates temporary key if not provided)
OIDC_PRIVATE_KEY: ${OIDC_PRIVATE_KEY}
# Optional Configuration
FORCE_SSL: ${FORCE_SSL:-false}
volumes:
- ./storage:/rails/storage
restart: unless-stoppedCreate a .env file in the same directory:
Generate required secrets first:
# Generate SECRET_KEY_BASE (required)
openssl rand -hex 64
# Generate OIDC private key (optional - auto-generated if not provided)
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
cat private_key.pem # Copy the output into OIDC_PRIVATE_KEY belowThen create .env:
# Rails Secret (REQUIRED)
SECRET_KEY_BASE=paste-output-from-openssl-rand-hex-64-here
# Application URLs (REQUIRED)
CLINCH_HOST=https://auth.yourdomain.com
CLINCH_FROM_EMAIL=noreply@yourdomain.com
# SMTP Settings (REQUIRED for invitations and password resets)
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_DOMAIN=yourdomain.com
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
# OIDC Private Key (OPTIONAL - generates temporary key if not provided)
# For production, generate a persistent key and paste the ENTIRE contents here
OIDC_PRIVATE_KEY=
# Optional: Force SSL redirects (only if NOT behind a reverse proxy handling SSL)
FORCE_SSL=falseStart Clinch:
docker compose up -dFirst Run:
- Visit
http://localhost:3000(or your configured domain) - Complete the first-run wizard to create your admin account
- Configure applications and invite users
Upgrading:
# Pull latest image
docker compose pull
# Restart with new image (migrations run automatically)
docker compose up -dLogs:
# View logs
docker compose logs -f clinch
# View last 100 lines
docker compose logs --tail=100 clinchClinch stores all persistent data in the storage/ directory (or /rails/storage in Docker):
- SQLite database (
production.sqlite3) - Uploaded files via ActiveStorage (application icons)
Database Backup:
Use SQLite's VACUUM INTO command for safe, atomic backups of a running database:
# Local development
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"This creates an optimized copy of the database that's safe to make even while Clinch is running.
Full Backup (Database + Uploads):
For complete backups including uploaded files, backup the database and uploads separately:
# 1. Backup database (safe while running)
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup-$(date +%Y%m%d).sqlite3';"
# 2. Backup uploaded files (ActiveStorage files are immutable)
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
# Docker Compose equivalent
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
docker compose exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/Restore:
# Stop Clinch first
# Then restore database
cp backup-YYYYMMDD.sqlite3 storage/production.sqlite3
# Restore uploads
tar -xzf uploads-backup-YYYYMMDD.tar.gz -C storage/Docker Volume Backup:
Option 1: While Running (Online Backup)
a) Mapped volumes (recommended, e.g., -v /host/path:/rails/storage):
# Database backup (safe while running)
sqlite3 /host/path/production.sqlite3 "VACUUM INTO '/host/path/backup-$(date +%Y%m%d).sqlite3';"
# Then sync to off-server storage
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/b) Docker volumes (e.g., using named volumes in compose):
# Database backup (safe while running)
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
# Copy out of container
docker compose cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3Option 2: While Stopped (Offline Backup)
If Docker is stopped, you can copy the entire storage:
docker compose down
# For mapped volumes
tar -czf clinch-backup-$(date +%Y%m%d).tar.gz /host/path/
# For docker volumes
docker run --rm -v clinch_storage:/data -v $(pwd):/backup ubuntu \
tar czf /backup/clinch-backup-$(date +%Y%m%d).tar.gz /data
docker compose up -dImportant: Do not use tar/snapshots on a running database - use VACUUM INTO instead or stop the container first.
All configuration is handled via environment variables (see the .env file in the Docker Compose section above).
- Visit Clinch at
http://localhost:3000(or your configured domain) - First-run wizard creates initial admin user
- Admin can then:
- Create groups
- Invite users
- Register applications
- Configure access control
One advantage of being a Rails application is direct access to the Rails console for administrative tasks. This is particularly useful for debugging, emergency access, or bulk operations.
# Docker / Docker Compose
docker exec -it clinch bin/rails console
# or
docker compose exec -it clinch bin/rails console
# Local development
bin/rails console# Find by email
user = User.find_by(email_address: 'alice@example.com')
# Find by username
user = User.find_by(username: 'alice')
# List all users
User.all.pluck(:id, :email_address, :status)
# Find admins
User.admins.pluck(:email_address)
# Find users in a specific status
User.active.count
User.disabled.pluck(:email_address)
User.pending_invitation.pluck(:email_address)# Create a regular user
User.create!(
email_address: 'newuser@example.com',
password: 'secure-password-here',
status: :active
)
# Create an admin user
User.create!(
email_address: 'admin@example.com',
password: 'secure-password-here',
status: :active,
admin: true
)user = User.find_by(email_address: 'alice@example.com')
user.password = 'new-secure-password'
user.save!user = User.find_by(email_address: 'alice@example.com')
# Check if TOTP is enabled
user.totp_enabled?
# Get current TOTP code (useful for testing/debugging)
puts user.console_totp
# Enable TOTP (generates secret and backup codes)
backup_codes = user.enable_totp!
puts backup_codes # Display backup codes to give to user
# Disable TOTP
user.disable_totp!
# Force user to set up TOTP on next login
user.update!(totp_required: true)user = User.find_by(email_address: 'alice@example.com')
# Disable a user (prevents login)
user.disabled!
# Re-enable a user
user.active!
# Check current status
user.status # => "active", "disabled", or "pending_invitation"
# Grant admin privileges
user.update!(admin: true)
# Revoke admin privileges
user.update!(admin: false)user = User.find_by(email_address: 'alice@example.com')
# View user's groups
user.groups.pluck(:name)
# Add user to a group
family = Group.find_by(name: 'family')
user.groups << family
# Remove user from a group
user.groups.delete(family)
# Create a new group
Group.create!(name: 'developers', description: 'Development team')user = User.find_by(email_address: 'alice@example.com')
# View active sessions
user.sessions.pluck(:id, :device_name, :client_ip, :created_at)
# Revoke all sessions (force logout everywhere)
user.sessions.destroy_all
# Revoke a specific session
user.sessions.find(123).destroy# List all OIDC applications
Application.oidc.pluck(:name, :client_id)
# Find an application
app = Application.find_by(slug: 'kavita')
# Regenerate client secret
new_secret = app.generate_new_client_secret!
puts new_secret # Display once - not stored in plain text
# Check which users can access an app
app.allowed_groups.flat_map(&:users).uniq.pluck(:email_address)
# Revoke all tokens for an application
app.oidc_access_tokens.destroy_all
app.oidc_refresh_tokens.destroy_alluser = User.find_by(email_address: 'alice@example.com')
app = Application.find_by(slug: 'kavita')
# Revoke consent for a specific app
user.revoke_consent!(app)
# Revoke all OIDC consents
user.revoke_all_consents!Clinch has comprehensive test coverage with 341 tests covering integration, models, controllers, services, and system tests.
# Run all tests
bin/rails test
# Run specific test types
bin/rails test:integration
bin/rails test:models
bin/rails test:controllers
bin/rails test:system
# Run with code coverage report
COVERAGE=1 bin/rails test
# View coverage report at coverage/index.htmlClinch uses multiple automated security tools to ensure code quality and security:
# Run all security checks
bin/rake security
# Individual security scans
bin/brakeman --no-pager # Static security analysis
bin/bundler-audit check --update # Dependency vulnerability scan
bin/importmap audit # JavaScript dependency scanContainer Image Scanning:
# Install Trivy
brew install trivy # macOS
# or use Docker: alias trivy='docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy'
# Build and scan image (CRITICAL and HIGH severity only, like CI)
docker build -t clinch:local .
trivy image --severity CRITICAL,HIGH --scanners vuln clinch:local
# Scan only for fixable vulnerabilities
trivy image --severity CRITICAL,HIGH --scanners vuln --ignore-unfixed clinch:localCI/CD Integration: All security scans run automatically on every pull request and push to main via GitHub Actions.
Security Tools:
- Brakeman - Static analysis for Rails security vulnerabilities
- bundler-audit - Checks gems for known CVEs
- Trivy - Container image vulnerability scanning (OS/system packages)
- Dependabot - Automated dependency updates
- GitHub Secret Scanning - Detects leaked credentials with push protection
- SimpleCov - Code coverage tracking
- RuboCop - Code style and quality enforcement
Current Status:
- β All security scans passing
- β 341 tests, 1349 assertions, 0 failures
- β No known dependency vulnerabilities
- β Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
- π‘ 3 outstanding security issues (all MEDIUM/LOW priority)
Security Documentation:
- docs/security-todo.md - Detailed vulnerability tracking and remediation history
- docs/beta-checklist.md - Beta release readiness criteria
- Rails 8.1 - Modern Rails with authentication generator
- SQLite - Lightweight database (production-ready with Rails 8)
- Tailwind CSS - Utility-first styling
- Hotwire - Turbo and Stimulus for reactive UI
- ROTP - TOTP implementation for 2FA
- bcrypt - Password hashing
MIT