I wanted login for a static app without standing up a server. Here’s how I made it actually secure with end‑to‑end encryption (E2EE) on s5: derive keys from the user’s password in the browser, encrypt all sensitive data client‑side, and store only ciphertext in s5. Authentication is simply being able to decrypt.
E2EE model: I use a publishable, collection-scoped API key plus client‑side key derivation and AES‑GCM. The server only ever sees ciphertext and non‑secret metadata. No plaintext passwords or profile data leave the browser.
Why I picked s5 for authentication
I needed three things: simple storage, flexible querying, and a clean JS SDK. s5 gives me all of that without booting a server. Documents are just JSON, optimistic locking is built-in, and the query DSL is expressive enough to find users by email or combine conditions like role and status. It felt like using a familiar document DB, but with great HTTP ergonomics.
If you're building a static site or SPA and want to avoid server maintenance while keeping control over your data model, s5 is a solid fit.
Key management and least privilege
- Use a publishable API key intended for clients.
- Scope it to only the
userscollection; block access to everything else. - Allow only the methods you need (read, create, and partial update for password resets).
- Lock CORS to your production domains.
- Set sensible rate limits for sign up and login endpoints.
This setup ensures even if the key is inspected, it can only perform tightly constrained actions that you expect in the client.
Threat model at a glance
Client-only auth means you trade off server-side secrecy for simplicity. Here's how I mitigate risk:
- Password safety: Hash in-browser with Argon2 or bcrypt; never transmit plaintext.
- Key exposure: The key is publishable and narrowly scoped; it cannot list or mutate other sensitive collections.
- Enumeration: Use generic error messages and delay on failures to make brute force harder.
- Session: Store only the minimal user identifier client-side. No secrets in localStorage.
- Rate limits + bot checks: Throttle sign up/login attempts and add bot protection.
Password reset without a server
Two pragmatic approaches I've used:
- Magic link via email provider: Use a third‑party email service with action links that deep‑link back to your app and allow setting a new password. The new password is hashed client-side and saved via a PATCH.
- Recovery codes: On sign up, generate and display one‑time recovery codes; store their hashes in the user document. A user can use a recovery code to set a new password, again hashing in-browser.
PATCH /api/v1/users/{id}/patch
If-Match: W/"1"
{
"data": { "password_hash": "<argon2-encoded-hash>" }
}
Logout and session storage
For purely client-side apps, I keep session minimal:
- Store
session_user_idand a timestamp inlocalStorage. - On app boot, check freshness; if it's old, force re-login.
- Implement a quick logout that clears storage and navigates to
/login.
Rate limiting and abuse prevention
Enable rate limiting in your s5 configuration for read/write operations on the users collection. Combine this with client‑side backoff on failures and optional bot protection on your forms.
SPA setup tips (React or Vue)
- Load your publishable s5 key from environment (e.g.,
PUBLIC_S5_KEY). - Wrap auth helpers in a dedicated module for reuse across components.
- Normalize emails (trim, lowercase) before queries to avoid duplicates.
- Handle slow networks by showing intent (disabled buttons, spinners).
FAQs
Is client-side hashing safe?
Yes, provided you use a modern KDF (Argon2 or bcrypt), never send plaintext, and constrain the client key’s capabilities. This pattern is similar to password managers and end‑to‑end encrypted apps.
Can I prevent email enumeration?
Return generic errors for login failures and add small delays after attempts. Consider a soft‑CAPTCHA or proof‑of‑work for excessive retries.
What about roles and permissions?
Store an array like roles: ["user","admin"]. For admin‑only client areas, validate role on the document before rendering.
Troubleshooting
- Login always fails: Ensure you normalized emails the same way on sign up and login; verify your hashing/verification functions are paired (Argon2 ↔ Argon2).
- CORS errors: Add your app domain(s) to allowed origins in s5 settings.
- Rate limit hits: Add exponential backoff and limit retries in the client.
Performance notes
Argon2/bcrypt are intentionally slow. That’s good for security, but do show a spinner. Keep your user documents lean so queries stay snappy. For lists, paginate and use the query parameters covered in the API docs pagination.
Alternatives and when to add a server
If you need SSO, complex policies, or server‑side sessions, consider adding a tiny backend or using a managed auth provider. The nice part is you can start client‑only with s5 and evolve later without throwing away your user data model.
Data model (encrypted)
We’ll use a users collection. Store only non‑secret metadata in plaintext; everything else is encrypted:
{
"data": {
"email_hash": "<sha256(lowercase(email))>", // for lookup
"kdf_salt": "<base64 16 bytes>", // for Argon2 KDF
"enc_profile": { // AES-GCM ciphertext
"iv": "<base64 12 bytes>",
"ciphertext": "<base64>"
},
"enc_check": { // decrypt == success
"iv": "<base64>",
"ciphertext": "<base64 of 'ok' or random tag>"
},
"created_at": "2025-10-30T00:00:00Z"
}
}
Key derivation and encryption
Derive a symmetric key from the user’s password with argon2id, then encrypt with AES‑GCM via WebCrypto. No password hashes are stored; we verify by decrypting a known blob.
Derive key and encrypt (browser)
import argon2 from 'argon2-browser';
async function deriveKey(password, saltB64) {
const salt = saltB64 ? Uint8Array.from(atob(saltB64), c => c.charCodeAt(0)) : crypto.getRandomValues(new Uint8Array(16));
const { hash } = await argon2.hash({ pass: password, salt, type: argon2.ArgonType.Argon2id, time: 3, mem: 64 * 1024, hashLen: 32, parallelism: 1 });
const keyMaterial = Uint8Array.from(atob(hash), c => c.charCodeAt(0));
const cryptoKey = await crypto.subtle.importKey('raw', keyMaterial, 'AES-GCM', false, ['encrypt','decrypt']);
return { cryptoKey, saltB64: btoa(String.fromCharCode(...salt)) };
}
async function aesGcmEncrypt(cryptoKey, plaintextObj) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(JSON.stringify(plaintextObj));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, data));
return { iv: btoa(String.fromCharCode(...iv)), ciphertext: btoa(String.fromCharCode(...ct)) };
}
async function aesGcmDecrypt(cryptoKey, { iv, ciphertext }) {
const ivBytes = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
const ctBytes = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0));
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBytes }, cryptoKey, ctBytes);
return JSON.parse(new TextDecoder().decode(new Uint8Array(pt)));
}
Sign up flow (E2EE, no server)
- Collect email + password in the client.
- Derive a key from the password with Argon2id and a random salt.
- Encrypt a profile blob and an enc_check value using AES‑GCM.
- Save to s5 with
email_hash,kdf_salt,enc_profile,enc_check. - Store a minimal client session (user id) in
localStorage.
import S5 from 's5-orm.js';
import argon2 from 'argon2-browser';
const s5 = new S5({ apiKey: import.meta.env.PUBLIC_S5_KEY });
const User = s5.collection('users');
async function sha256(input) {
const data = new TextEncoder().encode(input.trim().toLowerCase());
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
}
export async function signupClient({ email, password, profile }) {
const { cryptoKey, saltB64 } = await deriveKey(password);
const enc_profile = await aesGcmEncrypt(cryptoKey, profile);
const enc_check = await aesGcmEncrypt(cryptoKey, { ok: true });
const email_hash = await sha256(email);
const user = await User.create({
email_hash,
kdf_salt: saltB64,
enc_profile,
enc_check,
created_at: new Date().toISOString()
});
localStorage.setItem('session_user_id', user.id);
return user;
}
Login flow (E2EE, no server)
- Look up user by
email_hash. - Derive the key with stored
kdf_saltand entered password. - Decrypt
enc_check; if it works, login succeeded. - Optionally decrypt
enc_profileand store in memory.
export async function loginClient({ email, password }) {
const email_hash = await sha256(email);
const users = await User.where({ q: [ `eq(email_hash,\"${email_hash}\")` ] });
const user = users[0];
if (!user) throw new Error('Invalid credentials');
const { cryptoKey } = await deriveKey(password, user.data.kdf_salt);
try {
const check = await aesGcmDecrypt(cryptoKey, user.data.enc_check);
if (!check.ok) throw new Error('Invalid');
} catch (_) {
throw new Error('Invalid credentials');
}
localStorage.setItem('session_user_id', user.id);
return { user, profile: await aesGcmDecrypt(cryptoKey, user.data.enc_profile) };
}
Hardening tips I used: long Argon2 params, publishable key scoped to users, CORS locked to my domain, pagination + rate limiting, bot protection on forms, and no plaintext secrets at rest or in transit.
Frontend examples (React and Vue)
React (hooks)
import { useState } from 'react';
import S5 from 's5-orm.js';
import argon2 from 'argon2-browser';
const s5 = new S5({ apiKey: import.meta.env.PUBLIC_S5_KEY });
const User = s5.collection('users');
export default function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setLoading(true);
try {
const users = await User.where({ q: [ `eq(email,\"${email}\")` ] });
const user = users[0];
if (!user) throw new Error('Invalid credentials');
const ok = await argon2.verify({ encoded: user.data.password_hash, pass: password });
if (!ok) throw new Error('Invalid credentials');
localStorage.setItem('session_user_id', user.id);
alert('Logged in');
} catch (e) {
alert(e.message || 'Login failed');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-3">
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" className="border p-2 w-full" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" className="border p-2 w-full" />
<button disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded">{loading ? '...' : 'Login'}</button>
</form>
);
}
Vue 3 (SFC)
<script setup>
import { ref } from 'vue';
import S5 from 's5-orm.js';
import argon2 from 'argon2-browser';
const s5 = new S5({ apiKey: import.meta.env.PUBLIC_S5_KEY });
const User = s5.collection('users');
const email = ref('');
const password = ref('');
const loading = ref(false);
async function onSubmit() {
loading.value = true;
try {
const users = await User.where({ q: [ `eq(email,\"${email.value}\")` ] });
const user = users[0];
if (!user) throw new Error('Invalid credentials');
const ok = await argon2.verify({ encoded: user.data.password_hash, pass: password.value });
if (!ok) throw new Error('Invalid credentials');
localStorage.setItem('session_user_id', user.id);
alert('Logged in');
} catch (e) {
alert(e.message || 'Login failed');
} finally {
loading.value = false;
}
}
</script>
<template>
<form @submit.prevent="onSubmit" class="space-y-3">
<input v-model="email" placeholder="Email" class="border p-2 w-full" />
<input type="password" v-model="password" placeholder="Password" class="border p-2 w-full" />
<button :disabled="loading" class="bg-blue-600 text-white px-4 py-2 rounded">{{ loading ? '...' : 'Login' }}</button>
</form>
</template>
These components call your server endpoints, which in turn call s5 using the official SDK.
In this setup, the client talks directly to s5 with a publishable, scoped key.
Useful queries with s5 (metadata only)
Find by email
GET /api/v1/users?q=eq(email_hash,"<sha256(lowercase(email))>")
Find active admins
GET /api/v1/users?q=eq(role,"admin")&q=eq(status,"active")
Partial updates (e.g., password reset)
PATCH /api/v1/users/{id}/patch
If-Match: W/"1"
{
"data": { "password_hash": "<new-hash>" }
}
Wrapping up
With a tiny server for hashing and calling s5, you can add robust authentication to static frontends. Use the s5-orm.js SDK for ergonomics, and lean on s5's querying and optimistic locking for clean, composable user management flows.