An embeddable, framework-agnostic authentication engine for Go. Import it, configure it, own your users.
import "github.com/crydensync/cryden/v2"Every project ends up rewriting auth from scratch, or handing user data to a third-party provider. CrydenSync is a library, not a service — your users, sessions, and audit logs stay in your own database, under your own control.
- Own your users — no hosted service, no data leaving your infrastructure
- No vendor lock-in — plain Postgres tables, no proprietary format
- Framework-agnostic — no request/response objects, no assumptions about your HTTP layer
- Zero telemetry — the engine never phones home. Logs and audit events go wherever you wire them, never to us
go get github.com/crydensync/cryden/v2Runs with zero setup using the in-memory store — good for trying it out or writing tests:
package main
import (
"context"
"os"
"github.com/crydensync/cryden/v2"
"github.com/crydensync/cryden/v2/store/memory"
)
func main() {
ctx := context.Background()
engine, err := cryden.New(cryden.Config{
JWTSecret: os.Getenv("JWT_SECRET"),
Users: memory.NewUserStore(),
Sessions: memory.NewSessionStore(),
Audit: memory.NewAuditStore(),
})
if err != nil {
panic(err)
}
user, err := cryden.SignUp(ctx, engine, "proguy@example.com", "Pass@2026", "1.2.3.4")
if err != nil {
panic(err)
}
tokens, err := cryden.Login(ctx, engine, "proguy@example.com", "Pass@2026", "1.2.3.4", "some-user-agent")
if err != nil {
panic(err)
}
userID, err := cryden.VerifyToken(engine, tokens.AccessToken)
_ = user
_ = userID
}- Run the migration in
store/postgres/migrations/0001_initial_schema.up.sqlagainst your database. - Requires Postgres 13+ (uses the built-in
gen_random_uuid()). - Swap the memory stores for the Postgres ones:
import (
"database/sql"
_ "github.com/lib/pq"
"github.com/crydensync/cryden/v2/store/postgres"
)
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
engine, err := cryden.New(cryden.Config{
JWTSecret: os.Getenv("JWT_SECRET"),
Users: postgres.NewUserStore(db),
Sessions: postgres.NewSessionStore(db),
Audit: postgres.NewAuditStore(db),
})Works with any standard Postgres — Supabase, Neon, RDS, self-hosted, etc. If your provider offers both a direct and a connection-pooled URL, use the direct (or session-mode pooled) connection string — the engine relies on multi-statement transactions during token rotation, which can misbehave under transaction-mode pgbouncer poolers.
After repeated failed login attempts, an account is locked for a configurable duration — persistent in the database, not in-memory, so it holds even through restarts or multiple running instances. Defaults to 5 attempts / 15 minutes; override via Config.LockoutThreshold and Config.LockoutDuration.
RequestEmailChange and ConfirmEmailChange require two additional Config fields that are otherwise optional:
engine, err := cryden.New(cryden.Config{
// ...required fields...
Verifications: postgres.NewVerificationStore(db), // or memory.NewVerificationStore()
EmailSender: myEmailSenderImpl, // you implement notify.EmailSender
})The engine never sends email itself — implement notify.EmailSender against whatever provider you use (SendGrid, SES, SMTP), and build the actual verification URL yourself; the engine only hands you a raw token, it has no idea what your app's domain or routes look like. Calling RequestEmailChange without these configured returns cryden.ErrEmailChangeNotConfigured rather than panicking.
The engine never performs an HTTP redirect and never talks to a specific provider — that's inherently HTTP-shaped work that belongs in your API layer. By the time you call into the engine, your app has already completed the provider's redirect/callback flow and confirmed the person's identity:
engine, err := cryden.New(cryden.Config{
// ...required fields...
OAuth: postgres.NewOAuthStore(db), // or memory.NewOAuthStore()
})
tokens, err := cryden.LoginWithOAuth(ctx, engine, "google", externalID, email, callerIP, userAgent)LoginWithOAuth also doubles as signup — if neither an existing link nor an existing account matches, a new user is created automatically. If the email matches an existing password-based account that isn't linked yet, it returns *auth.ErrOAuthEmailConflict (retrievable via errors.As) rather than auto-linking — auto-linking on email match alone is an account-takeover vector if a provider's email verification ever has an edge case. Resolve it by having the person log in with their password first, then call:
err := cryden.LinkOAuthIdentity(ctx, engine, userID, "google", externalID, email, callerIP)userID must come from an already-verified session — never trust an email alone to authorize a link. Calling either function without Config.OAuth set returns cryden.ErrOAuthNotConfigured.
Requires two additional Config fields:
engine, err := cryden.New(cryden.Config{
// ...required fields...
TOTP: postgres.NewTOTPStore(db), // or memory.NewTOTPStore()
EncryptionKey: os.Getenv("ENCRYPTION_KEY"), // separate secret from JWTSecret
TOTPIssuerName: "YourApp", // shown in the user's authenticator app
})EncryptionKey is required whenever TOTP is set — a TOTP secret has to be recoverable in plaintext to validate codes against it, so (unlike passwords and tokens) it's encrypted rather than hashed. Use a different value from JWTSecret, not the same one twice.
Enrollment is a two-step confirm flow — a secret never gates login until the user proves they've actually captured it:
otpauthURL, err := cryden.EnrollTOTP(ctx, engine, userID)
// render otpauthURL as a QR code for the user to scan
err = cryden.ConfirmTOTP(ctx, engine, userID, codeFromApp)
// only after this succeeds does the account require a code to log inOnce confirmed, Login no longer issues tokens directly for that account — it returns *auth.ErrSecondFactorRequired (retrievable via errors.As) carrying a short-lived pending token and the list of enrolled second-factor methods:
tokens, err := cryden.Login(ctx, engine, email, password, callerIP, userAgent)
var secondFactor *auth.ErrSecondFactorRequired
if errors.As(err, &secondFactor) {
// secondFactor.Methods is e.g. []string{"totp"} — prompt accordingly, then:
tokens, err = cryden.CompleteLoginWithTOTP(ctx, engine, secondFactor.PendingToken, code, callerIP, userAgent)
}The pending token expires after 5 minutes and is only ever valid for completing that one login — it's a distinct token type from an access token, not just a permissive one. DisableTOTP(ctx, engine, userID, currentPassword) removes 2FA from an account and requires the current password as re-confirmation. Calling any TOTP function without Config.TOTP set returns cryden.ErrTOTPNotConfigured.
Passkeys are supported as an additional second-factor method, unified with TOTP under the same *auth.ErrSecondFactorRequired pause state — an account can have TOTP, a passkey, both, or neither; Login reports whichever are enrolled via Methods and the caller picks. (Passwordless primary login via passkeys — no password step at all — isn't built yet; this is 2FA on top of a password, same as TOTP.)
Requires four additional Config fields, all required together:
engine, err := cryden.New(cryden.Config{
// ...required fields, EncryptionKey (shared with TOTP if both are configured)...
WebAuthn: postgres.NewWebAuthnStore(db), // or memory.NewWebAuthnStore()
WebAuthnRPID: "yourapp.com", // your real domain — see note below
WebAuthnRPDisplayName: "Your App Inc", // shown in the browser's passkey prompt
WebAuthnRPOrigins: []string{"https://yourapp.com"},
})WebAuthnRPID is a genuine security parameter, not cosmetic like TOTPIssuerName — passkeys are cryptographically bound to it, and a credential registered against one RPID will never validate against another. WebAuthnRPOrigins must exactly match what the browser actually sends.
Registration is a begin/finish ceremony — the engine never talks to the browser directly, it only produces and consumes the JSON payloads:
creationOptionsJSON, ceremonyToken, err := cryden.BeginRegisterPasskey(ctx, engine, userID)
// forward creationOptionsJSON to the browser's navigator.credentials.create() call
err = cryden.FinishRegisterPasskey(ctx, engine, userID, ceremonyToken, clientResponseJSON, "MacBook Touch ID")
// clientResponseJSON is the raw JSON body the browser call resolved withceremonyToken is the WebAuthn ceremony's own short-lived challenge state, encrypted with the same EncryptionKey used for TOTP secrets — pass it through unmodified, there's no separate ephemeral store to manage.
Login completion is a three-call sequence — Login pauses the same way it does for TOTP, but the passkey ceremony itself is its own begin/finish round trip on top of that:
tokens, err := cryden.Login(ctx, engine, email, password, callerIP, userAgent)
var secondFactor *auth.ErrSecondFactorRequired
if errors.As(err, &secondFactor) {
// secondFactor.Methods might be []string{"webauthn"} or []string{"totp", "webauthn"}
assertionOptionsJSON, ceremonyToken, err := cryden.BeginWebAuthnLogin(ctx, engine, secondFactor.PendingToken)
// forward assertionOptionsJSON to navigator.credentials.get()
tokens, err = cryden.CompleteLoginWithWebAuthn(ctx, engine, secondFactor.PendingToken, ceremonyToken, clientResponseJSON, callerIP, userAgent)
}ListPasskeys(ctx, engine, userID) lists registered passkeys (nickname, creation time, last used). DeletePasskey(ctx, engine, userID, credentialID, currentPassword) removes one — requires the current password, same reasoning as DisableTOTP. Calling any passkey function without Config.WebAuthn set returns cryden.ErrWebAuthnNotConfigured.
Requires one additional Config field:
engine, err := cryden.New(cryden.Config{
// ...required fields, and Verifications (shared with email-change tokens)...
MagicLinkSender: yourMagicLinkSender, // implements notify.MagicLinkSender
})MagicLinkSender is a separate interface from EmailSender — not a new method added to it, since EmailSender already shipped and adding a required method would break every existing implementation. Config.Verifications must also be set; magic-link tokens reuse the same store email-change tokens use, distinguished by purpose internally.
This logs in an existing account only — it doesn't create one:
err := cryden.RequestMagicLink(ctx, engine, email, callerIP)
// always nil for a nonexistent email too (avoids leaking which emails are registered);
// a real delivery failure for an existing account still returns as an error
tokens, err := cryden.CompleteMagicLink(ctx, engine, rawTokenFromTheLink, callerIP, userAgent)The link is valid for 15 minutes and single-use — clicking it a second time fails the same way an expired one does. Like Login, CompleteMagicLink routes through the same second-factor gate: an account with TOTP/a passkey enrolled returns *auth.ErrSecondFactorRequired here exactly as it would after a correct password — clicking the link proves email ownership, the primary factor, not a bypass of a confirmed second one. Calling either function without Config.MagicLinkSender set returns cryden.ErrMagicLinkNotConfigured.
Requires one additional Config field:
engine, err := cryden.New(cryden.Config{
// ...required fields...
RecoveryCodes: postgres.NewRecoveryCodeStore(db), // or memory.NewRecoveryCodeStore()
})Generating a batch requires the account to already have a confirmed TOTP secret or a registered passkey — codes exist to recover access to a real second factor, not to stand in as one on their own:
codes, err := cryden.GenerateRecoveryCodes(ctx, engine, userID)
// show `codes` to the user ONCE — the engine only ever stores their hashes
// and can never display them again after this call returnsGenerating a fresh batch always replaces the previous one in full — every old code, used or not, stops working immediately. Completion works the same way TOTP does:
tokens, err := cryden.CompleteLoginWithRecoveryCode(ctx, engine, secondFactor.PendingToken, code, callerIP, userAgent)One safety property worth knowing: "recovery_code" only ever appears in Login's Methods list alongside "totp" and/or "webauthn" — never on its own. If an account's last real second factor gets disabled while unconsumed codes still exist in storage, those codes stop being offered at all, rather than silently becoming a standalone permanent backdoor into the account. Calling either function without Config.RecoveryCodes set returns cryden.ErrRecoveryCodesNotConfigured.
engine, err := cryden.New(cryden.Config{
// ...required fields...
BreachedPasswordChecker: yourChecker, // implements security.BreachedPasswordChecker
})Ships zero implementations — checking a password against a breach database means an outbound network call (e.g. to HIBP's Pwned Passwords API, which uses k-anonymity so you never send the actual password), and the engine doesn't talk to the internet on its own initiative anywhere else in this codebase, so it doesn't start here either. A minimal HIBP implementation looks roughly like:
type hibpChecker struct{ client *http.Client }
func (h *hibpChecker) IsBreached(ctx context.Context, password string) (bool, error) {
sum := sha1.Sum([]byte(password))
hash := strings.ToUpper(hex.EncodeToString(sum[:]))
prefix, suffix := hash[:5], hash[5:]
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.pwnedpasswords.com/range/"+prefix, nil)
resp, err := h.client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return strings.Contains(string(body), suffix), nil
}Checked on SignUp and ChangePassword, after the password policy (cheap, local checks first) and after ChangePassword's current-password verification (a new password's breach status should never leak to someone who hasn't already proven they own the account). A checker error fails open — SignUp/ChangePassword proceed rather than blocking on a third-party API's uptime; only a confirmed breach (true, nil) rejects the password with auth.ErrPasswordBreached.
engine, err := cryden.New(cryden.Config{
// ...required fields...
PasswordPolicy: security.PasswordPolicy{
MinLength: 12,
RequireUppercase: true,
RequireDigit: true,
},
})Unlike TOTP/WebAuthn/recovery codes, this has no "unconfigured means off" state — leaving PasswordPolicy as the zero value applies security.DefaultPasswordPolicy instead (MinLength: 8, MaxLength: 72, no character-class requirements, following NIST 800-63B guidance that length matters far more than forced complexity rules). MaxLength defaults to 72 specifically because that's bcrypt's own real limit — without this check, a longer password hits a raw bcrypt library error at hash time instead of a clean validation error.
A violation returns *auth.ErrPasswordPolicyViolation{Violations []string} — every broken rule at once ("min_length", "max_length", "require_uppercase", "require_lowercase", "require_digit", "require_symbol"), not just the first one hit, so you can show a user everything wrong with their password in one pass instead of a fix-resubmit-discover-the-next-problem loop. These are stable machine-readable codes, not display strings — the engine doesn't own UI copy or localization anywhere else, so it doesn't start here either.
The ai subpackage provides the safety machinery for natural-language admin tooling — an allowlisted QueryIntent type, validateIntent, and ExecuteQuery — plus store/postgres.SafeQueryStore, a read-only query executor. This is a foundation for tools like csax's CLI to build on, not a feature you call directly in application code. An LLM's output is treated as untrusted data to validate against a strict allowlist, never as SQL to execute — and the actual DB connection passed to SafeQueryStore must be opened with a read-only Postgres role, since that's the real safety boundary, not just the allowlist check. ai.LLMProvider ships zero implementations; bring your own (OpenAI, Anthropic, OpenRouter, a local model).
- Signup, login, logout (single device + all devices)
- OAuth login/signup (Google, GitHub, or any provider) with explicit, non-auto-linking account collision handling — see OAuth
- Two-factor authentication: TOTP and passkeys (WebAuthn), unified under one pause state — see Two-factor authentication and Passkeys
- Magic-link (passwordless) login for existing accounts, routed through the same second-factor gate — see Magic-link login
- Recovery (backup) codes as a second-factor fallback, with a safety guard against becoming a standalone backdoor once the real factor is removed — see Recovery codes
- Breached-password checking (interface-only, bring your own HIBP/etc.) and a configurable, secure-by-default password policy — see Breached-password check and Password policy
- JWT access tokens + rotating opaque refresh tokens with theft/reuse detection
- Session listing and revocation
- Change password (requires current password, revokes all other sessions)
- Change email (requires verification of the new address before it takes effect)
- Delete account (requires current password)
- Persistent, DB-backed account lockout after repeated failed login attempts — survives restarts, correct across multiple instances
- Email verification primitives (token issue/confirm) — delivery is pluggable via the
notify.EmailSenderinterface, the engine never sends email itself - Rate limiting, bcrypt password hashing, audit logging
- Pagination and system-wide read facades (
ListAll,Count,CountActive,SearchByType,GetUser,ListPublicSessions) for building admin tooling on top of the engine aisubpackage — allowlisted, read-only query safety layer for AI-assisted admin tooling built on top of this engine (see AI-assisted admin queries)- One storage backend: Postgres (interface-based, more can be added later)
CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. SMS OTP, SAML, and other advanced auth methods are planned for later releases. Passkeys are currently second-factor only — passwordless primary login via passkeys (no password step at all) is a planned fast-follow now that magic-link forced the shared "login without a password" plumbing to exist.
MIT — see LICENSE.