A small, self-hosted identity service: one Go binary, one SQLite file, and no external identity provider.
Auth gives a group of applications one place for sign-in, account access, two-factor policy, and global sign-out. Applications receive short-lived, Ed25519-signed identity assertions and keep their own host-only sessions, roles, and data.
These are real captures from the embedded server-rendered UI. No frontend build or external asset host is involved.
Administrator portal — accounts, application grants, teams, and 2FA state
Application launcher — one sign-in, only the apps this account can use
Password sign-in — brandable, responsive, and dark/light aware
It runs in one of two modes, chosen per deployment:
- Password mode (new deployments). Administrators create accounts; people
sign in with email and password. Applications integrate through an
OpenID Connect style authorization-code handoff (
/authorize,/token, discovery, JWKS) and, if they register a back-channel endpoint, receive a signed, versioned desired membership whenever an administrator grants or revokes access, plus a signed logout when a sign-out, password change or disablement revokes the person's sessions. Two-factor sign-in with an authenticator app (TOTP) can be turned on by each person or required by policy. A web admin console at/adminmanages accounts, per-application access, two-factor policy and sign-outs. Seedocs/AUTH_V2_IMPLEMENTATION.mdfor the design. - Magic-link mode (legacy). People type an email address and click a
one-time link. The result is one Ed25519-signed cookie on a shared parent
domain, which every subdomain either verifies natively or has Caddy verify
for it with
forward_auth.
Password mode is the recommended choice for new deployments. Magic-link mode remains for stacks that intentionally share a parent-domain cookie.
- See it in action · What it supports · Architecture
- Quickstart · Environment · Tests · Tools
- Deploying to production · Token format · Layout
- Login UI fonts · Why not a hosted provider?
- Security · Contributing · License
browser
▼
┌─────────────────────────┐
│ auth.example.com │ TLS terminated by Caddy
│ ─────────────────── │
│ Caddy → :9000 │
│ auth-server (Go) │
│ SQLite │
│ SendGrid / SMTP │ (magic links; optional notices)
└────────────┬────────────┘
│
password mode: /authorize → code → /token → application session
magic mode: Set-Cookie <cookie> Domain=example.com
▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ app-a.example.com │ │ app-b.example.com │
│ own host-only session │ │ Caddy forward_auth │
│ after the code handoff │ │ → /verify → headers │
└─────────────────────────┘ └─────────────────────────┘
Password mode. An application redirects the browser to /authorize with
its registered client ID, exact callback, state, nonce and an S256 PKCE
challenge. Auth signs the person in (or reuses the live central session),
checks that the account has been granted that application, and returns a
60-second single-use code. The application exchanges it at /token with
client authentication and receives standard identity claims plus an
EdDSA-signed id_token, then mints its own host-only session. The Auth
cookie is host-only and never shared with an application. Password
replacement, account disablement and sign-out revoke the central session and
queue one signed back-channel logout per application that registered a
back-channel endpoint, delivered by a retrying worker. Passive expiry sends
nothing.
Application grants use a separate latest-desired-state outbox. Auth retries them until acknowledged, with a monotonic version per account/application, so an application that was offline converges when it returns and an old delivery cannot undo a newer administrator choice. Applications still enforce their own roles and retain their own data; Auth can carry validated app-specific settings such as Fleet's Chat and Ops roles.
Magic-link mode. Auth emails a one-time link signed with its Ed25519 key.
Clicking it sets a signed cookie on AUTH_COOKIE_DOMAIN, so it rides to every
subdomain. Downstream services either verify the cookie themselves with the
public key, or let Caddy call /verify and pass X-User-Email and
X-User-Tenant headers to the upstream. Tenant is the email domain; the
allowlist of domains that may request a link is managed with auth domain.
Which services sit behind an Auth deployment, and how each one verifies, is
documented per deployment, not here.
docs/INTEGRATION.md has the generic patterns and
checklists.
Prerequisite: Go 1.25.14 or a compatible newer toolchain.
# Build both binaries.
make build
# Create a local-only configuration, then generate a signing key.
cp .env.local.example .env.local
./bin/auth-admin keygen
$EDITOR .env.local
# For a plain-HTTP password-mode development server, set:
# AUTH_SIGNING_KEY=<the private seed printed above>
# AUTH_ADDR=127.0.0.1:9000
# AUTH_HOSTNAME=localhost:9000
# AUTH_DATA_DIR=.localdata
# AUTH_LOGIN_MODE=password
# AUTH_ALLOW_INSECURE_DEV=true
# AUTH_COOKIE_DOMAIN=
# AUTH_COOKIE_SECURE=false
# AUTH_PASSWORD_COOKIE_NAME=auth_session
# AUTH_EMAIL_DRIVER=stdout
# Create the first account. The password is read without echo and is not
# placed in shell history.
mkdir -p .localdata
AUTH_DATA_DIR=.localdata ./bin/auth-admin user create alice@example.com
AUTH_DATA_DIR=.localdata ./bin/auth-admin user admin alice@example.com on
# Start the server, then open http://localhost:9000.
make runThe runtime default email driver is stdout, so a local magic-link flow can
work without an email provider when AUTH_LOGIN_MODE=magic,
AUTH_ALLOW_INSECURE_DEV=true, and AUTH_COOKIE_SECURE=false: each one-time
link prints to the terminal. This override is accepted only on a loopback
hostname and issuer.
The reference .env.local.example selects SendGrid to make its production
requirements visible; change it to stdout for local development.
Set AUTH_LOGIN_MODE=password to use administrator-provisioned email and
password accounts instead of magic links. This is also the default when the
setting is omitted. For plain-HTTP local development, also set
AUTH_ALLOW_INSECURE_DEV=true, AUTH_COOKIE_SECURE=false, and
AUTH_PASSWORD_COOKIE_NAME=auth_session; production keeps the secure
__Host-auth_session default. Create more accounts
without placing their passwords in shell history:
make build
mkdir -p .localdata
AUTH_DATA_DIR=.localdata ./bin/auth-admin user create alice@example.comThe CLI prompts for a 12 to 128 character password and requires the person to replace it after the first login. Register each application with its exact callback and optional logout URL; the generated secret is displayed once and only its SHA-256 hash is stored:
AUTH_DATA_DIR=.localdata ./bin/auth-admin app create explorer \
https://explorer.example.com/auth/callback \
https://explorer.example.com/signed-out
AUTH_DATA_DIR=.localdata ./bin/auth-admin app set-backchannel explorer \
https://explorer.example.com/auth/backchannel-logoutDiscovery lives at /.well-known/openid-configuration and the current plus
overlapping rotation keys are published at /jwks.json. An account may only
sign in to applications it has been granted (auth user access, or the
console). Consumers use each back-channel token's jti for idempotency.
See .env.local.example for the full catalog. Minimum to start:
AUTH_SIGNING_KEY: base64 Ed25519 private seed. Generate a keypair withmake build && ./bin/auth-admin keygen; this is the private half.
For anything beyond localhost, also set:
AUTH_HOSTNAME: the public hostname.AUTH_LOGIN_MODE:passwordormagic.- In magic mode,
AUTH_COOKIE_DOMAIN(parent of every subdomain that should see the cookie),AUTH_ALLOWED_DOMAINS(comma-separated email-domain allowlist; empty means open enrollment, never in production), andAUTH_EMAIL_DRIVERplusSENDGRID_API_KEYor theAUTH_SMTP_*settings withAUTH_EMAIL_FROM. - In password mode, email is optional. With
sendgridorsmtpconfigured, Auth sends best-effort security notices about two-factor and recovery-code changes; withstdoutor no driver it sends nothing and the audit log is the record. - Never set
AUTH_ALLOW_INSECURE_DEV=trueoutside loopback development. The server rejects it with a public hostname or issuer. - Optionally
AUTH_CLIENT_CONFIG_DIR: a checkout of a client bundle whosebranding:block supplies the wordmark, logo, colours and login copy. Unset means the default look.
Optional abuse caps on /magic (sensible defaults apply if unset):
AUTH_MAGIC_RATE_PER_EMAIL: links per email per 15 min (default10).AUTH_MAGIC_GLOBAL_LIMIT: links across all emails per 60 min (default500). Set either to0to disable. See docs/DEPLOY.md for details.
# Full gate: lint + vet + build + test
make check
# Go tests plus the operator CLI and doctor shell tests
make test
# Boot real binaries and drive the sign-in and application handoff flows
make smoke
# Just one package
go test ./internal/store -v
# Coverage
go test -cover ./...Two tests worth highlighting:
TestMagicLinkConcurrentConsumeOnlyOneWins(internal/store) fires 8 simultaneousConsumeMagicgoroutines at the same nonce and asserts exactly one wins. The core single-use invariant of magic-link mode.TestReturnToForeignHostFallsBack(internal/httpapi): even thoughreturn_tois signed inside the magic link, the post-login redirect re-checks the allowlist. An attacker who got a victim to submit/magicwithreturn_to=https://evil.com/can't pivot.
Password mode has its own suites for the code handoff, per-application access, the admin console, back-channel delivery and the last-administrator guard.
make build # compile auth-server + auth-admin into ./bin
make run # build + start with local .env.local
make test # go test ./... + operator shell tests
make lint # gofmt -s check + pinned golangci-lint
make check # lint + vet + build + test (pre-push gate)
make smoke # end-to-end flows against throwaway databases
make tidy # go mod tidy
make clean # rm -rf binOn a fresh Fedora/RHEL box:
curl -fsSL https://github.com/ghraw/ElcanoTek/auth/main/install.sh | sudo bashThe installer adds Git and CA certificates, clones main into
/opt/auth-src, and starts the interactive bootstrap. If you prefer to inspect
it first, download install.sh and run it with sudo bash.
bootstrap.sh is interactive. It asks for the hostname and login mode; magic
mode additionally asks for its cookie domain and email provider:
- Hostname (e.g.
auth.example.com) - Login mode:
passwordfor a new deployment ormagicfor a legacy shared-cookie stack. - In magic mode, cookie domain and email provider.
Plus a yes/no for "set up Caddy + Let's Encrypt for this hostname?". The
script generates the Ed25519 signing keypair (and prints the public key to
copy to verifying services), builds the binary, drops a systemd unit,
optionally provisions Caddy, opens 80/443 in firewalld, and installs
/usr/local/bin/auth for operations.
Day-to-day:
auth user create alice@example.com # password mode: provision an account
auth user admin alice@example.com on # let them open the admin console
auth app create <id> <callback> # register an application
auth domain add example.com # magic mode: allow an email domain
auth pubkey # print AUTH_SIGNING_PUBKEY for verifiers
auth restart # pick up new .env.local
auth logs # journalctl -fu auth-server
auth backup # online sqlite snapshot
auth doctor # read-only box check; add --json for a machine report
sudo auth doctor --repair # fix env and data-dir mode; start a stopped unit
auth update # git pull + rebuild + restart (auto-rolls-back a bad build)See docs/DEPLOY.md for the full walkthrough, the password-mode rollout checklist, the admin console, backup and restore, TLS options, secret rotation, and uninstall.
See docs/INTEGRATION.md for how an application or service verifies identity in each mode, plus a checklist for new services.
Magic-link mode. The session cookie value is
base64url(payload_json).base64url(ed25519_sig). Payload is
{"email":"…","tenant":"…","iat":…,"exp":…}. The signature is Ed25519 over
the base64url body string.
Signing is asymmetric: auth-server holds the private key (AUTH_SIGNING_KEY)
and is the only party that can mint a token. Verifying services hold only the
public key (AUTH_SIGNING_PUBKEY), enough to validate a cookie, never to
forge one. The public key is safe to distribute, and a leak there can't
impersonate anyone. Services that verify on their own need about 50 lines plus
the public key; internal/token/token.go is the Go reference.
Password mode. The central session is an opaque 256-bit token stored only
as its SHA-256 hash; the browser holds it in a host-only cookie. Identity
reaches an application as an EdDSA-signed id_token (standard claims: iss,
sub, aud, iat, exp, nonce, auth_time, amr, acr; the JOSE
header's kid names the signing key) and
sign-outs arrive as signed logout+jwt back-channel tokens, both verifiable
against /jwks.json.
cmd/auth-server/ long-running HTTP service
cmd/auth-admin/ CLI behind the `auth` operator wrapper
internal/config/ env loading + validation
internal/token/ Ed25519-signed magic, session, identity and logout tokens
internal/store/ SQLite (modernc.org/sqlite, no CGO)
internal/password/ password policy + Argon2id hashing
internal/mfa/ TOTP, recovery codes, sealed authenticator secrets
internal/branding/ client bundle branding
internal/backchannel/ durable back-channel logout delivery
internal/provisioning/ durable application-access (grant/revoke) delivery
internal/email/ SendGrid / SMTP / stdout drivers
internal/httpapi/ HTTP routes + login, account and admin UI templates
deploy/ systemd units + Caddy + operator CLI
scripts/ bootstrap, update, doctor, envfile helpers, smoke tests
docs/AUTH_V2_IMPLEMENTATION.md password-mode design and invariants
docs/DEPLOY.md production walkthrough
docs/INTEGRATION.md integration patterns and checklists
The pages self-host their typeface from the binary: //go:embed in
internal/httpapi/fonts.go, served at /fonts/ with
Cache-Control: immutable. No Google Fonts, no CDN: an external font
dependency on a login page is both a privacy leak and a third party in the
login path, and a self-contained binary is the deploy model here anyway.
The face is Nebula Sans (SIL OFL 1.1), Elcano's brand face. Only the two
weights these pages render are embedded, 400 for body copy and 700 for
headings, labels and the button, which keeps the payload at about 144 KB.
internal/httpapi/fonts/OFL.txt ships and is served alongside the woff2 files
because the licence requires the licence text to travel with the binaries. To
change the face, replace the woff2 files and licence, then adjust the
@font-face rules in internal/httpapi/templates.go and the embed patterns
in fonts.go together. TestFontsServed and TestFontLicenceShipped fail if
either drifts.
Hosted identity services (Clerk, Stytch, WorkOS and similar) are a good fit for many teams. Auth exists for deployments that prefer a small surface they own: sessions stay out of a third party's hot path, one email provider account can serve the whole stack, and there is no per-user pricing. SAML and upstream identity federation are not supported today.
Auth keeps private signing material, client secrets, password hashes, authenticator seeds, and live sessions out of Git:
- Application secrets are displayed once and stored only as SHA-256 hashes.
- Passwords are hashed with Argon2id; authenticator seeds are sealed with AES-256-GCM under a separate deployment key.
- Password-mode browser sessions are opaque, host-only,
HttpOnly,SameSite=Lax, andSecureby default. - Authorization codes are short-lived, single-use, callback-bound, and require S256 PKCE; signed assertions publish their verification keys through JWKS.
- CI scans every checked-out file and the full Git history with gitleaks,
including on documentation-only changes. Local
.envfiles, SQLite state, and build outputs are ignored.
The full operational threat model and hardening checklist live in
docs/DEPLOY.md. Please report vulnerabilities privately as
described in SECURITY.md.
Contributions are welcome. See CONTRIBUTING.md for the
local checks, repository map, and pull-request expectations.
Auth is available under the MIT License. The embedded Nebula Sans
font files are distributed separately under the SIL Open Font License 1.1 in
internal/httpapi/fonts/OFL.txt.


