CrydenSync

Engineering Review

An independent, code-level review of the Cryden engine: what it does, how it is built, and where the honest gaps are.

This page is an independent, code-level review of the cryden engine as it stands today, written by reading the actual source rather than the marketing around it. It is reproduced here close to verbatim, including the gaps it found, because a review that only reports strengths is not a review.

Overview

Cryden (module github.com/crydensync/cryden/v2) is an embeddable, framework-agnostic authentication engine for Go. It is a library rather than a service: consuming applications import the root package, inject their own storage and delivery dependencies through Config, and keep users, sessions, passwords, second factors, API keys, and audit history inside their own database. The positioning is explicit throughout the codebase and README: no hosted service, no vendor lock-in, no telemetry, no outbound calls made by the engine on its own initiative.

The codebase is a Go module targeting Go 1.25, laid out as a thin public facade plus internal implementation packages. The root package cryden exposes the Engine type, Config, and roughly forty package-level functions that form the entire outward API surface. Implementation detail lives in auth (password, OAuth, second-factor, API-key, and account flows), token (JWT issuance and verification plus opaque refresh-token rotation), session (listing, revocation, device labels), security (hashing, rate limiting, TOTP and WebAuthn providers, anomaly and stuffing scoring, user-agent and geolocation helpers), store (domain types and interfaces), store/memory, store/postgres, and store/sqlite (backends), notify (sender seams), logger (operational logging toolkit), admin (read-only reporting), and ai (allowlisted natural-language query safety). The repository also contains cmd/smoketest with nineteen standalone demo commands that exercise each feature end to end with no database required.

Architecture

Engine construction follows strict dependency injection. Config requires JWTSecret, Users, Sessions, and Audit, all of which must be supplied explicitly, and every optional capability is wired by assigning an interface implementation: Verifications, EmailSender, MagicLinkSender, OAuth, TOTP, WebAuthn, RecoveryCodes, APIKeys, Anomalies, RateLimiter, Hasher, BreachedPasswordChecker, Geolocator, AccessTokenClaims, Webhooks, and WebhookEvents among others. New validates the configuration and fails loudly at construction time for dangerous gaps, such as TOTP or WebAuthn configured without EncryptionKey, MagicLinkSender without Verifications, WebhookEvents without Webhooks, or an invalid API key prefix. Optional knobs that are safe to default are filled by applyDefaults, including a fifteen-minute access token TTL, bcrypt cost 10, 32-byte refresh tokens, a 10-attempt-per-minute in-memory rate limit, and a 5-attempt, 15-minute persistent account lockout.

A deliberate and consistently applied rule is that an unconfigured optional capability is off rather than silently insecure. TOTP, WebAuthn, magic links, recovery codes, API keys, and email change return typed ErrXNotConfigured sentinels instead of panicking, and Login never checks for a second factor unless the relevant store is configured. The one exception is the password policy, where the zero value means DefaultPasswordPolicy rather than no policy, because shipping an auth engine that accepts any password by default was judged worse than applying a sensible NIST-inspired floor. The same reasoning recurs in the anomaly thresholds, credential-stuffing thresholds, and Argon2id parameters, all of which interpret an entirely zero-valued struct as "use the defaults."

The codebase is unusually disciplined about side effects. The admin package is documented and structurally enforced as read-only through narrow interfaces such as AuditReader that omit Record. The weekly digest builds through that interface, and its smoke test reads the same history twice to prove nothing was written. Logger, EmailSender, MagicLinkSender, WebhookSender, BreachedPasswordChecker, IPGeolocator, and LLMProvider ship as interfaces only, with the engine holding the seam and the host owning delivery, which keeps the zero-telemetry promise structural rather than aspirational.

Accounts and passwords

SignUp validates the password policy, optionally consults the breached-password checker, hashes with the configured primary hasher, and records audit events. Login rate limits per caller IP and email, looks up the user, pays the hashing cost even for a nonexistent email to equalize timing against a wrong password, returns the same ErrInvalidCredentials in both cases, increments failed attempts, and locks the account in the database after the threshold. Lockout is persistent and database-backed, so it survives restarts and is correct across multiple instances, which the in-memory rate limiter is explicitly documented not to be. ChangePassword requires the current password, checks policy and breach status after ownership is proven, and revokes all other sessions. DeleteAccount requires the current password as re-confirmation. Logout, LogoutAll, and session revocation verify ownership before acting.

Email verification and change

RequestEmailChange and ConfirmEmailChange implement a two-step flow where the new address takes effect only after a token from the store is confirmed. Delivery is through the notify.EmailSender seam, and the engine hands over only a raw token, never a URL, since routing and domain belong to the host. Tokens are single-use, expiring, hashed at rest, and distinguished by purpose, so an email-change token cannot be replayed as a magic link or vice versa.

OAuth

LoginWithOAuth is provider-agnostic and runs after the host has completed the provider redirect. It doubles as signup, creating a user when neither a link nor a matching account exists. When the provider email matches an existing password-based account that is not yet linked, it returns ErrOAuthEmailConflict rather than auto-linking, on the theory that auto-linking on email match alone is an account-takeover vector. The intended resolution is a password login followed by LinkOAuthIdentity with a user ID that comes from an already-verified session. OAuth logins route through the same second-factor gate as password and magic-link logins.

Second factors

TOTP, WebAuthn passkeys, and recovery codes share one unified pause state. Login returns ErrSecondFactorRequired, retrievable with errors.As, carrying a short-lived, single-use pending token (five minutes) and the list of enrolled methods, and completion happens through CompleteLoginWithTOTP, the WebAuthn begin and finish ceremony pair, or CompleteLoginWithRecoveryCode. The pending token is its own token type rather than a permissive access token. TOTP secrets are encrypted with AES-GCM derived from Config.EncryptionKey rather than hashed, because validating a code requires recovering the secret, and enrollment is a two-step confirm flow, so a secret never gates login before the user proves possession. Passkeys are cryptographically bound to WebAuthnRPID and use real begin and finish ceremonies with encrypted ceremony state, and credentials are stored as opaque blobs with a denormalized credential ID for indexed matching. Recovery codes are single-use, stored only as hashes, shown exactly once at generation, and each new batch atomically replaces the old one. A notable safety property is that recovery codes are only advertised in the Methods list alongside a real second factor, so they can never silently become a standalone permanent backdoor if the real factor is later removed. Disabling TOTP and deleting a passkey require the current password as re-confirmation.

RequestMagicLink logs in existing accounts only, returns nil for nonexistent emails to avoid enumeration, and delivery is through the notify.MagicLinkSender seam, deliberately a separate interface from EmailSender to avoid breaking existing implementations. Links are valid for fifteen minutes, single-use, and completion routes through the same second-factor gate as a password login.

Breach checking and password policy

BreachedPasswordChecker is an interface with zero shipped implementations, because checking requires an outbound call to a third party. It is checked on SignUp and ChangePassword, after local policy checks and after ownership is proven, and checker errors fail open, so a third-party outage cannot block legitimate signups. Only a confirmed breach rejects the password. Password policy violations return ErrPasswordPolicyViolation, carrying every broken rule at once as stable, machine-readable codes such as min_length and require_uppercase. The default policy is a length floor of 8 and a ceiling of 72, the latter matching bcrypt's own limit, with no forced character classes.

Tokens and sessions

Access tokens are HMAC-signed JWTs with a default fifteen-minute TTL and carry the standard registered claims. A ClaimsProvider seam allows the host to attach its own claims, such as role or tenant ID; the seven registered RFC 7519 names are reserved and refused, provider errors fail the login rather than issue an under-authoritative token, and VerifyTokenWithClaims reads the extra claims back. Refresh tokens are opaque 256-bit random values stored only as SHA-256 hashes and rotated on every refresh through an atomic store operation. Rotation keeps sessions in families, and presenting an already-rotated token is treated as theft: the entire family is revoked and a token_reuse_detected audit event is recorded. Sessions carry IP and user agent supplied by the caller, and public read facades such as ListSessions, ListPublicSessions, GetUser, paginated store methods, and system-wide counts support building admin tooling without bypassing the store layer.

Named sessions

ListNamedSessions turns a raw session list into the shape a devices settings page needs. Each entry embeds a redacted PublicSession plus a parsed Device, an optional Location, and a composed Label, such as "Chrome on Windows" or "Chrome on Windows, San Francisco, CA." The device half is computed by a built-in user-agent parser with explicit bot detection and form-factor classification. The location half is host-supplied through the IPGeolocator interface, called once per distinct IP per request, and errors fail open to a device-only label. Labels are derived on read, so sessions created before the feature existed get labels retroactively, with no migration.

Machine credentials

API keys are a second, parallel way to authenticate as a user, outside the session and second-factor systems on purpose, since there is no human at the end of a machine request. GenerateAPIKey returns the raw key exactly once, prefixed with a configurable non-secret label such as ck_, and the store holds only its SHA-256 hash. Keys carry host-defined opaque scopes, an optional expiry, a coarse last-used timestamp updated at most every five minutes, and revocation that cannot be undone. AuthenticateAPIKey is a single indexed lookup with uniform ErrInvalidAPIKey failures for unknown, revoked, expired, or malformed keys, so callers cannot probe which stolen keys are still live, and revocation is scoped to the owning user in a single statement. Audit events cover creation, revocation, and presentation of dead keys.

Detection features

Login anomaly detection is on when Config.Anomalies is set, and otherwise does nothing. It evaluates each completed primary authentication against a baseline drawn from recent successful logins only, so failed attempts cannot teach the baseline, and flags six signals: new_ip, new_device, user_failure_velocity, ip_failure_velocity, token_reuse, and concurrent_sessions. Detection is report-only by design; a flagged attempt records an anomaly_detected audit event with the signals in its metadata and never blocks, delays, or forces step-up, and storage failures degrade to no evidence. Credential-stuffing detection shares the same login-attempt history and covers the pattern per-account lockout structurally cannot see: one IP spraying many accounts. It evaluates breadth across distinct existing accounts plus failures against unknown emails, and emits account_spray or unknown_account_spray signals, with a cooldown to avoid drowning monitoring in duplicate events, and is also strictly report-only. Both detection families live in security as pure arithmetic over plain observations, keeping the scoring testable without a store.

Distributed rate limiting

The default limiter is an in-process fixed-window counter, documented as correct for exactly one process. Config.RateLimiter accepts any security.RateLimiter, and the shipped RedisRateLimiter keeps the counter in Redis through an atomic INCR and PEXPIRE script, so every engine instance shares one window. It works with redis.Client, ClusterClient, and Ring because each Allow touches one key, and it fails closed when Redis is unreachable, an availability trade-off documented as deliberate. A custom key prefix is supported for deployments sharing one Redis database.

Password hashing

Bcrypt at cost 10 is the default. Config.Hasher accepts an alternative, and the shipped Argon2idHasher implements RFC 9106 parameters (64 MiB, t equals 3, p equals 4 by default) with PHC-format output. The engine always wraps the primary in a MultiHasher that identifies the algorithm from each stored hash's own prefix, so switching hashers never locks out existing users and needs no migration. On a successful password login where the stored hash is out of date, the engine rewrites it with the current hasher and records a password_hash_upgraded audit event carrying the from and to algorithm, which is how an entire user base actually migrates over time.

Audit logging and webhooks

The audit store records 31 typed domain events covering signups, logins, failures, lockouts, token rotation and reuse, password and email changes, second-factor enrollment and challenges, OAuth linking, API key lifecycle, anomaly and stuffing detection, and hash upgrades. Events carry user, IP, and typed metadata, and the store interfaces support per-user listing, system-wide search by type, and exact counts by type over a window. Audit writes are treated as best-effort notifications rather than gates. Webhooks are wired as a decorator around the audit store, so every audited event is a potential delivery point with no second mechanism to forget at future call sites. Config.Webhooks takes a notify.WebhookSender implementation; delivery is synchronous on the request path, an error is logged and never fails the operation being reported, a panic propagates, and the event carries a stable delivery ID for idempotent receivers. The default event set is the actionable low-volume subset, and deliberately excludes login_success, login_failed, and token_rotated, which would otherwise swamp an endpoint.

Weekly digest

The admin package builds a plain-text report over a window of audit history, exposed through cryden.WeeklyDigest for the default seven-day window and cryden.DigestSince for a custom start. Counts are exact and come from the database for every type, including types the engine does not define, so a host's own events are reported rather than dropped. Attention-worthy events such as lockouts, token reuse, anomalies, and stuffing bursts are spelled out individually, newest first, capped at ten per type, with a note when detail is truncated. The report is grouped into needs-attention, accounts, sign-ins, sign-in-methods, and API-keys sections, is deterministic, prints nothing for event types that did not occur, and renders in UTC. Unknown types appear under an "other events" section. A broken store is an error, never a digest that falsely reads as a quiet week, and the whole feature is read-only by construction.

Support-ticket assistant

cryden.DiagnoseLoginIssue turns the support question of why a user cannot log in into a plain-text answer built from what is already recorded. The report covers whether the account is locked and until when, its current failed-attempt count, how many sessions it holds right now, and the newest failure events by type, including failed TOTP and passkey challenges, rejected recovery codes, and anomaly or stuffing flags. admin.DiagnoseLogin accepts narrow interfaces that omit lock, reset, and revoke methods, so producing the report cannot change the account it reports on, and a nonexistent account is itself the answer rather than an error.

Config tuning advisor

cryden.ConfigTuningReport and cryden.TuningReportSince read a window of audit history, 30 days by default through admin.DefaultTuningWindow, against the engine's own settings: lockout threshold and duration, whether the default in-process rate limiter is still in use, anomaly and credential-stuffing thresholds, and whether breached-password checking is active. admin.BuildTuningReport takes plain TuningInputs rather than a live engine, so the report is a pure read over history and current knob values. It never applies a suggestion, and the output is a text report a human reads before changing anything in Config.

Ask-AI widget

The widget package is the one AI-assisted surface built for a host application's own end users rather than admins, which makes prompt injection a real threat model. widget.Ask takes the same ai.LLMProvider and ai.QueryableStore interfaces as admin tooling, plus an ownerUserID that must come from the host's own authentication, and force-rewrites every parsed QueryIntent, so its identity-bearing filter names that user before validation or execution. Overwrite rather than validate-and-reject is the deliberate design: a reject path would have to trust the filter enough to compare it and would turn "did you try someone else's data" into a probeable oracle, while overwrite makes every phrasing of a question produce the same scoped query. An entity scopeToOwner cannot bind to one owner fails closed. A nil Composer falls back to the deterministic RenderResult table, so no second model call is required, and the whole package is additive: it reuses ai.ExecuteIntent, which ExecuteQuery now shares, with behavior pinned by a parity test.

AI-assisted admin queries

The ai package provides the safety machinery for natural-language admin tooling. An LLM's output is treated as untrusted data: provider output must parse into a strictly typed QueryIntent whose entity, fields, operators, aggregates, and limits are validated against allowlists before any query executes. Only the users, sessions, and audit_events entities are reachable, password hashes and token hashes are excluded from every field list, and result sizes are capped. ExecuteQuery composes provider parsing, validation, and a QueryableStore, and the production implementations in store/postgres and store/sqlite must be driven by a read-only database role, because that credential boundary, not the allowlist alone, is the real safety guarantee.

Storage backends

Three backends implement the store interfaces. store/memory provides in-memory stores for every capability, and makes the quickstart and smoke tests run with zero setup. store/postgres is the primary production backend, uses lib/pq, requires Postgres 13 or later for gen_random_uuid, relies on multi-statement transactions for atomic token rotation, and ships sequential SQL migrations 0001 through 0007 covering the initial schema, OAuth identities, TOTP secrets, WebAuthn credentials, recovery codes, login attempts, and API keys. store/sqlite is a second production backend that imports no SQLite driver at all and speaks only database/sql, so the host chooses mattn, modernc, or ncruces and passes an opened *sql.DB. The SQLite package documents the DSN pragmas that matter (foreign_keys, busy_timeout, WAL), ships Migrate and CheckPragmas helpers, stores timestamps as fixed-format UTC text, and is exercised in tests by the pure-Go modernc driver, so go test works with CGO disabled.

Logging

The logger package defines a frozen Logger interface and ships a JSON-per-line ConsoleJSONLogger as the default. Around that interface it provides ContextLogger and LogFunc for trace correlation, LevelFilter to drop debug records before a vendor charges for them, a MaskingRedactor to hash or mask IP addresses before records leave the infrastructure, and MultiLogger to fan out to several sinks while preserving full detail locally. The intended composition redacts inside the fan-out rather than around it, and flushing is deliberately the host's job, because the host owns the sink's lifecycle.

Error design

Errors are typed and documented for the cases a caller must branch on: ErrSecondFactorRequired carries the pending token and methods, ErrOAuthEmailConflict and ErrOAuthIdentityAlreadyLinked describe account collisions, ErrPasswordPolicyViolation lists every broken rule, and store errors such as ErrNotFound and ErrSessionNotOwned keep ownership failures distinguishable from missing rows. Uniform opaque errors are used where revealing a distinction would aid probing, as with API key failures. Unconfigured capabilities return clear sentinel errors rather than panics, and constructor validation catches configuration mistakes at New time.

Testing and verification

The repository contains 206 Go files, 83 of which are tests, with roughly 22,500 lines of implementation and 15,700 lines of tests across 14 packages plus the smoke-test commands. The full suite passed on the reviewing machine with one exception: a timing-assertion test in package auth failed once under full-suite load on an ARM device, because the measured timing ratio between the dummy-hash path and the wrong-password path dipped below the assertion, and it passed three times in isolation. The test looks environment-sensitive rather than indicative of a real regression, but it is worth loosening or marking on slow CI hardware. Postgres integration tests skip cleanly when DATABASE_URL is unset, SQLite tests run real on-disk databases through the pure-Go driver, and WebAuthn tests drive genuine registration and login ceremonies through the virtualwebauthn library. GitHub Actions CI builds, vets, and tests on ubuntu-latest against a Postgres 16 service with all migrations applied. The smoke-test commands are the most distinctive part of the testing story: each is a standalone, no-database program that walks through a feature as a user would and checks outcomes with assertion helpers. The newest smoke tests, for the config tuning advisor, support-ticket assistant, and ask-ai widget, cover the newest feature tier end to end, and widget.Ask has 14 tests, including a simulated prompt-injection case where the model output names a different user's ID, and that identity never reaches the fake store.

Documentation and project hygiene

The codebase is exceptionally well documented at the source level; package docs and exported identifiers read like design notes, and several tests exist specifically to pin design verdicts, such as the reflection test that prevents Config from ever growing email-template knobs, and the parity test that pins widget.Ask and ai.ExecuteQuery to the same validation tail.

Overall assessment

Cryden reads as a mature, opinionated, and unusually thoughtful authentication library. The dominant strengths are the explicit reasoning about fail-open versus fail-closed behavior, the structural enforcement of read-only reporting and no outbound telemetry, the careful layering that keeps HTTP, UI, email, and LLM concerns behind interfaces the host implements, the breadth of second-factor and machine-credential support under one consistent login-pause state, and a testing story that combines unit tests, real-ceremony passkey tests, backend integration tests, and standalone smoke tests. The main gaps are a handful of timing-sensitive tests that can flake on slow hardware, and the fact that some capabilities, such as SMS OTP, SAML, and passwordless primary passkey login, are still explicitly out of scope. None of these undercut the core design, which is coherent and easy to navigate.

A note from these docs

The section above is reproduced close to verbatim from an independent review, gaps included, because that is the honest version. Having gone through the same source to write the rest of this documentation set, the assessment matches what is actually in the code: the discipline is consistent rather than occasional. The same "fail loudly on a real misconfiguration, fail open on a third-party outage, never take an action a human did not ask for" pattern shows up in the password policy, the breach checker, the anomaly detector, and the Ask-AI widget alike, written by people who clearly kept re-deriving the same principle rather than copying it once and forgetting why. That kind of consistency is rarer than it should be, and worth calling out plainly rather than leaving unsaid.