Login without a backend: member, bcrypt, and a sealed cookie

A food diary needs an account: your targets and your log are private data, and they have to survive a device switch. We ruled out a custom backend for this back at the architecture stage. DynapiCMS ships presets for member authentication — ready-made flows for registration, login, and password recovery. We applied them in batches and rewrote nothing from scratch.
The member type and the bcrypt type
The user is an ordinary content type in the schema, same as a food product or a diary entry. The core of the schema:
{ "name": "email", "type": "string", "required": true, "unique": true },
{ "name": "password", "type": "bcrypt", "required": true },
{ "name": "verified", "type": "boolean" },
{ "name": "norm_kcal", "type": "int" }
Everything interesting hides in the bcrypt type. On write, the platform hashes the value itself and mixes in the project pepper — a secret that lives only on the server side. On read, the field returns "***" to any API client. The hash itself sits in the member entry, where it belongs. Bcrypt is irreversible, so a database dump gives up no passwords. The hash doesn’t leak through the API either: everyone except the server-side flow execution sees the asterisks, and that server-side execution is the only thing that can compare a password at login.
After that, we never had to touch the password ourselves — the best endorsement the presets could ask for.
Registration: a ready-made flow plus 15 lines of validation
The register preset is an eight-node graph. We applied it with flowctl apply, plugging in our domain and SMTP secret, and stepped right into a trap: the preset checks neither email format nor password length. Validation went into a condition node and a failure branch ahead of entry creation, and the graph grew to nine nodes:
{ "field": "form.email",
"operator": "matches",
"value": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" },
{ "field": "form.password",
"operator": "matches",
"value": ".{8,}" }
When a check fails, the client gets a redirect to /register?failed=1 and no entry is created. After a successful check, the flow looks for a duplicate with a query on email, creates the member, and sends a confirmation email. The signed_token filter builds the link right in the email template:
{{ results.m.members[0].password | signed_token:
results.m.members[0].id, "verify", 86400
| prepend: "https://calories.dynapi.ru/verify?token=" }}
The token is signed by the platform and lives for 86400 seconds, one day. Password reset uses the same mechanism with an hourly TTL. You can’t forge such a token or stretch its lifetime outside the platform.
The entry-creation node carries secret_vars: ["password"] — the platform scrubs the password from flow run logs.
The session cookie
Login is a six-node flow: find the member by email, compare the password with the bcrypt_matches operator, set the cookie, redirect to /app:
{ "action_type": "set_cookie",
"config": {
"name": "member",
"value": "{\"id\":\"{{results.login.members[0].id}}\"}",
"ttl_days": 30, "path": "/", "session": true
} }
The client never sees or reads the cookie. The platform seals the value — a JWT wrapped in AES-GCM — and sends it with HttpOnly; Secure; SameSite=Lax; Max-Age=2592000. Thirty days with no “remember me” checkbox: the PWA simply opens already logged in. Templates and flows pull member.id and member.name out of the cookie. Only the server can unwrap it.


Data isolation: the chapter that matters most
Every diary entry carries an owner field. The “mine only” filter looks like this:
entries(where: {client_uuid: {eq: $uuid}, owner: {eq: $member}}, limit: 1)
The $member variable in the flow’s graphql node is filled with {{ member.id }} — the platform resolves it from the signed cookie on the server. The platform’s own auth presets run on the same pattern. The user id always comes from the server, so there’s nothing to forge.
We tested isolation with two test accounts. B sees none of A’s entries. A save with someone else’s client_uuid creates a separate row for B, and the other account’s entry stays untouched. This check is part of the release routine: we run the two accounts through it every time, and “you can see it in the code” doesn’t count as an argument.
Gotchas
Login CSRF. The cookie is set only on a same-origin request: a cross-site POST to /login gets rejected with 403 REQUEST_ORIGIN_DENIED, and no cookie is set. For our forms this is protection. For anyone planning to embed a login form on someone else’s domain, the same behavior will come as a surprise.
A generic error message. A duplicate email and a bad password lead to the same page saying “Check your details”. The response makes it impossible to tell “email taken” from “password too short”, and that’s deliberate: enumerating registered addresses through a registration form is too cheap an attack vector to allow. Password recovery follows the same principle: the “we sent you an email” reply always arrives, even for an address that isn’t in the database.
Validation stays ours. The platform handles the cryptography and the sessions. Password policy remains our job, and the minimal set is the two condition nodes we added above.
What’s next
The next article is “A JSON API from flows”: the diary’s nine endpoints, the idempotent upsert on client_uuid that the offline sync rests on, and the gotchas of building JSON inside Liquid templates.
All posts