A single-binary password manager implementing envelope encryption (AES-256-GCM over an Argon2id-derived key) with a Go HTTP backend serving an embedded React SPA.
Live demo: https://nacl-g5mw.onrender.com
NaCl is a password manager. You create an account, you store the credentials you use to log into other services (your email, your social media, anything), and NaCl keeps them encrypted so they're unreadable to anyone who gets hold of the database.
The name comes from the chemical formula for salt, and a cryptographic salt is what NaCl uses to protect the key that encrypts your data.
One design choice is worth flagging up front: if you ever change your login password, none of your stored credentials need to be re-encrypted. The cost of a password rotation is constant, whether you've saved five credentials or five hundred. The rest of this document explains how that works.
NaCl is a monolithic web application: a Go HTTP backend serving a React 19 single-page application, both compiled into one self-contained binary.
The architecture is recorded as a set of Architecture Decision Records (ADRs) in docs/decisions.md, and the encryption design is fully documented in nacl_backend/docs/encryption_flow.md. The repository follows strict conventional commits and is exercised by two path-scoped GitHub Actions workflows.
- What is it?
- Overview
- Features
- How it works at a glance
- The crypto in short
- Envelope encryption and key rotation
- DTO contracts: Go structs and Zod schemas
- Tech stack
- Architecture
- Observability
- Quickstart
- Development
- CI / CD
- Disclaimers
- Roadmap
- Further reading
- Author & License
- Account management: register, log in, and rotate the login password.
- Credential vault: store service credentials with an optional description and encryption algorithm tag.
- Re-authentication on access: every encryption or decryption operation requires the user to re-enter the login password.
- Atomic key rotation: changing the login password re-encrypts only the master key; credential rows are untouched.
- Audit log: create, update, and delete operations are recorded per user and listed chronologically.
- Single-binary deployment: frontend and backend ship as one Go binary in an
alpineruntime image.
You register an account with a login password. Behind the scenes NaCl:
- Generates a 32-byte salt and a 32-byte random master key.
- Derives a wrapping key from your password + salt with Argon2id.
- Encrypts the master key with that wrapping key using AES-256-GCM.
- Stores the Argon2id password hash, salt, and encrypted master key, never your password.
When you save a credential (say, your Gmail password), NaCl:
- Asks you to re-enter your login password (re-authentication).
- Decrypts the master key in memory.
- Encrypts the service username and password with the master key.
- Writes the ciphertext to the database and logs the operation to the audit table.
When you change your login password, only the master key is re-encrypted; your stored credentials stay exactly as they are.
Two keys, not one:
- A random master key encrypts your credentials.
- Your login password derives a wrapping key (via Argon2id) that encrypts only the master key.
So when you change your login password, you only re-encrypt the master key: O(1), not O(vault size). The full design, including the registration, decryption, and rotation flows in detail, is in nacl_backend/docs/encryption_flow.md.
The two-key design earns its keep on password rotation. With a naive scheme that derives the encryption key directly from the login password, rotating your password means re-encrypting every credential row. NaCl avoids that: the master key does the bulk encryption work, and the login password only ever wraps the master key.
- Generate a 32-byte salt and a 32-byte random master key.
- Derive a wrapping key from the password + salt with Argon2id.
- Encrypt the master key with the wrapping key using AES-256-GCM.
- Store the Argon2id password hash, salt, and encrypted master key in the
userstable.
- Verify the login password against the Argon2id hash.
- Derive the wrapping key from the password + stored salt.
- Decrypt the master key in memory.
- Decrypt the credential's service username and password with the master key.
- Verify the old login password.
- Decrypt the master key with the old wrapping key.
- Derive a new wrapping key from the new password.
- Re-encrypt the master key with the new wrapping key.
- Update the
password_hashandencrypted_master_keyatomically in one transaction.
Credential rows are never read or written during rotation. The cost of a rotation is independent of how many credentials you have stored.
Full design document and rationale: nacl_backend/docs/encryption_flow.md. ADRs covering crypto choices: docs/decisions.md.
The Go struct in nacl_backend/internal/dto/dto.go is the single source of truth for request and response shapes. The frontend mirrors those shapes with Zod schemas in nacl_frontend/src/lib/: one set for requests, one for responses.
The crosswalk between the Go DTOs and the Zod schemas is documented in docs/dto-schema-map.md. If the two sides drift, the Zod schemas' .strict() mode rejects unknown keys at runtime, so a mismatch surfaces as a failing request rather than silent acceptance of an unexpected shape.
The same DTO types feed the generic DecodeAndValidate[T] helper used by every handler: the request is decoded into a typed struct and validated in one step, before the service layer is called. That keeps the handler layer doing only HTTP work, with no parsing or validation logic scattered across it.
Where to look:
nacl_backend/internal/dto/dto.go: request and response structs, theValidatorinterface, andDecodeAndValidate[T].nacl_frontend/src/lib/requestValidation.tsandresponseValidation.ts: the Zod mirror, including.strict()and date coercion viaz.string().pipe(z.coerce.date()).
For a full field-by-field mapping between the Go and TypeScript sides, see docs/dto-schema-map.md.
Written in Go. HTTP routing via chi, PostgreSQL access via pgx with sqlc query generation, migrations via goose, JWT auth (HS256), and Argon2id password hashing. AES-256-GCM is used for symmetric encryption of credentials and the master key.
Written in TypeScript on React 19, built with Vite. Forms via react-hook-form, runtime validation via zod (mirroring the backend DTOs), HTTP via axios. Plain CSS for styling.
- PostgreSQL for persistence.
- Docker for the runtime.
- Migrations run on application startup; no manual SQL step is needed.
flowchart LR
Browser["Browser\nReact SPA\n(Vite dev → proxies /api to backend)"] -->|HTTP /api/*| Router["Router"]
Router --> MW["Middleware\nlogging → recovery → JWT validation"]
MW --> Handler["Handler\n1. decode + validate DTO\n2. call service\n3. map errors to HTTP"]
Handler --> Service["Service\nusers · credentials · operations\ncrypto orchestration"]
Router --> Static["Embedded static FS\nSPA fallback"]
Static --> Browser
Three properties worth pointing out in this diagram:
- The handler layer is pure HTTP. Neither the database nor the encryption layer appears between the handler and the service. Handlers do only DTO decode + validate, call the service, and map errors to HTTP status codes.
- The embedded filesystem serves the SPA from the same binary. Client-side routes resolve on direct navigation or refresh, not only on client-side clicks.
- The service is the crypto orchestrator. Every operation that touches plaintext lives in the service, so the handler layer cannot accidentally leak plaintext or call crypto directly.
The layered design enforces separation of concerns by dependency direction. Each layer depends only on the one below it and exposes a narrow interface to the one above: the router dispatches to handlers, handlers speak only HTTP and DTOs, the service owns business logic and crypto orchestration, and the data layer is consumed by the service alone. Errors defined in the service are caught at the handler boundary and mapped to HTTP status codes, so transport concerns never leak into business logic and vice versa.
Architectural decisions are recorded as ADRs in docs/decisions.md.
NaCl uses Go's log/slog package for structured logging throughout the
request lifecycle, with a dual-handler design that separates
developer-facing diagnostics from operational log output.
On startup the server initialises a slog.Logger backed by two handlers
running simultaneously via slog.NewMultiHandler:
- A text handler writing to stderr at
Debuglevel -- human-readable output for local development and troubleshooting. - A JSON handler writing to a configurable log file at
Infolevel -- machine-parseable output suited for ingestion by log aggregators.
Because the two handlers are independent, debug-level diagnostics appear
on stderr during local work while only Info-and-above entries reach
the log file.
Every HTTP request passes through the RequestLogger middleware, which
captures and logs the method, path, response status code, client IP, and
wall-clock duration. A typical request produces:
{"time":"2026-06-12T18:09:25.863776373-06:00","level":"INFO","msg":"Served request","method":"GET","path":"/","status":200,"client_ip":"100.127.73.104","duration":6110}The status code is captured via a wrapper around the standard
ResponseWriter, so the log entry always reflects the final status code
regardless of where in the handler chain the request terminated.
Durations are reported in nanoseconds.
Errors carry structured attributes through the apperr package. The
logger's replaceAttr function intercepts any attribute whose key is
"error", unwraps the error chain, and emits the full chain as a group:
{
"time": "2026-06-12T20:56:18.760336969-06:00",
"level": "ERROR",
"msg": "could not create user",
"error": {
"message": "duplicate key: users_username_key",
"username": "some username",
"endpoint": "POST /api/users"
}
}The apperr.WithAttrs helper attaches key-value pairs at the call site,
and the logger surfaces them automatically without special handling in
the middleware or handler layers.
The Recovery middleware catches panics from downstream handlers, logs
the panic value as a structured error, and writes a 500 response.
Recovery at this boundary ensures a single panicking request cannot bring
down the process.
Create, update, and delete operations on credentials are recorded in a
dedicated operations database table, keyed by user. The audit log
provides an immutable history of vault changes independent of the
request log stream, accessible through GET /api/operations.
On receiving SIGINT or SIGTERM, the server drains in-flight requests
within a 5-second timeout window before exiting. Each shutdown phase is
logged at Debug level so the sequence is traceable in the output.
Requires Docker. Build and start both services:
git clone https://github.com/ManoloEsS/NaCl.git
cd NaCl
docker compose up --buildPostgreSQL starts first with a health check, then NaCl connects, runs migrations automatically, and serves the SPA on port 3333.
The container ships the frontend embedded in the Go binary; no separate web server is required.
# Run in the background
docker compose up --build -d
# Stop and remove the containers
docker compose downImportant notes:
- No persistent volume. Postgres data is stored inside the container
and will be lost when the container is removed. For evaluation and
development this is fine, but add a named volume if you need data to
survive
docker compose down. - Change the JWT secret. The default value in
docker-compose.ymlis a placeholder. ReplaceJWT_SECRET: "change-me-to-a-secret"with a strong random value before storing real data. - Log output. The text handler writes to stderr (visible in
docker compose logs). JSON logs go to a file inside the container by default; setSALT_LOG_FILEindocker-compose.ymlto redirect them.
Requires Go, Node, Docker, and the tools goose, sqlc, and air installed via go install ...@latest.
# From nacl_backend/: starts Postgres, applies migrations, hot-reloads Go
git clone https://github.com/ManoloEsS/NaCl.git
cd NaCl/nacl_backend
make dev-full# From nacl_frontend/: Vite dev server on :5173, proxies /api to :3333
cd NaCl/nacl_frontend
npm install
npm run devSee nacl_backend/docs/dev_env_config.md for the full prerequisites list.
# Build frontend into nacl_backend/static
cd nacl_frontend && npm ci && npm run build
# Build the Go binary (embeds static/)
cd ../nacl_backend && go build -o nacl .
./naclThe local development workflow uses Docker for PostgreSQL. You can also point
DATABASE_URL at any existing Postgres instance and skip the make db-*
commands below.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string for the dev database. |
DATABASE_URL_TEST |
tests | PostgreSQL connection string for the test database. |
JWT_SECRET |
yes (random fallback) | HS256 signing key. A random value is generated if missing. |
SALT_PORT |
no | Listen port. Falls back to PORT, then a platform default. |
PORT |
no | Alternate listen port (Render compatibility). |
SALT_LOG_FILE |
no | Path to the request log file. |
DB_SSL |
no | Enables SSL on the database connection. |
A working .env template is included and exported by the Makefile at the start of every invocation.
| Target | Effect |
|---|---|
make dev |
Hot-reload via air. |
make dev-full |
Start a Docker PostgreSQL container, create databases, migrate, then make dev (requires Docker). |
make db-start db-stop db-restart db-reset |
Start/stop/restart a Docker PostgreSQL container (requires Docker). |
make db-migrate db-down db-status |
Goose migrations. |
make db-new name=add_users |
Create a new timestamped migration. |
make db-test-clean |
Start an isolated Docker PostgreSQL container on port 5433 and apply migrations (requires Docker). |
make build run clean |
Compile, run, or remove the binary. |
make test |
Reset test DB and run go test ./... through tparse. |
make lint fmt fmt-check ci |
Mirror the CI checks. |
| Script | Effect |
|---|---|
npm run dev |
Vite dev server. |
npm run build |
tsc -b && vite build into ../nacl_backend/static. |
npm run lint |
ESLint. |
npm run format |
Prettier. |
npm test / npm run test:ui |
Playwright E2E. |
For the full list of API endpoints with request and response shapes, see docs/dto-schema-map.md.
Two workflows under .github/workflows/:
backend-ci.ymlruns on PRs tomaintouchingnacl_backend/**. It lints withgolangci-lint, then spins up a PostgreSQL service container, runs migrations andsqlc generate, builds, and runsgo test -race ./....frontend-ci.ymlruns on push and PR tomaintouchingnacl_frontend/**. It runs ESLint andtsc --noEmit, then Playwright, then a production build.
Both workflows are path-scoped, so a frontend-only change does not trigger the backend pipeline and vice versa.
See docs/ci_cd.md.
This is a learning project and is explicitly not production-ready. Please do not store real credentials in it.
The following are documented as out of scope for this MVP and recorded in docs/decisions.md and docs/user_stories.md:
- No rate limiting on authentication endpoints.
- No second-factor authentication.
- No token blacklist; logout is client-side only.
- No email verification or account lockout.
- No CAPTCHA.
- Server-side encryption. The master key exists in backend memory for the duration of each operation, so NaCl does not satisfy the strict zero-knowledge threat model of client-side encryption schemes. A compromised server can exfiltrate keys. This is acknowledged openly in
nacl_backend/docs/encryption_flow.md. - No ciphertext padding. AES-GCM ciphertext length leaks plaintext length. Acceptable for short password fields and consistent with industry practice, but documented as such.
Things we plan to work on in the future:
- 2FA / WebAuthn
- Server-side token blacklisting
- Rate limiting and account lockout
- Email verification and password reset
- True client-side encryption for a zero-knowledge posture
- Additional encryption algorithms to choose from
docs/decisions.md- the ADRs explaining why the architecture is the way it is.docs/user_stories.md- MVP scope and the explicit list of post-MVP features.nacl_backend/docs/encryption_flow.md- the full crypto design with diagrams.docs/dto-schema-map.md- how Go DTOs and Zod schemas stay in sync.nacl_backend/docs/dev_env_config.md- local prerequisites and tool installs.
The remaining files under docs/ and nacl_backend/docs/ are bonus material on specific topics (serving React from Go, error handling, migrations, frontend setup, CSS patterns).
Built by Manolo Estrada (@ManoloEsS).
Licensed under the MIT License.



