Every service in your system parses the Authorization header and reads the users table to find
out who called it. You add an accounts service so the others can ask by message instead. Then
someone turns on debug logging to chase a failed login, and the log file holds a customer’s
password in clear text, once for every message that carried it.
What you get
You will end up with register, login, session and logout messages over a users table with argon2id hashes, plus a Seneca logger that redacts secrets. This is for you if your services should ask who the caller is rather than each work it out.
Short answer
Give the service four messages, register, login, check session and logout, and let every other
service ask sys:account,check:session about a caller’s token. Hash with argon2id in the handler
that receives the password, and never forward the password in another message. Install a Seneca
logger that redacts secret-named fields, because debug logging writes every message in and out,
password included.
You will need
Node 22.18 or later, which runs TypeScript files without a build step, a Seneca service system,
and a database the accounts service can write to. The sample keeps its users table in a Map so
it runs anywhere; replace it with your database and a unique index on the handle. Splitting a
monolith into Seneca services is covered in the page on splitting a monolith into message-based
services, which is not published yet.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A users table with argon2 and your own session messages | The account model is yours to shape and the team can own security code | Every edge case is yours: expiry, rate limits, rehashing, reset flows and the tests for each | You need single sign-on, federation, or a security team’s audit trail you did not write |
| Keycloak | Several applications share one login, or you need OpenID Connect, federation and an administration console | A Java server and its database to run, patch and back up, and custom user fields through its user profile configuration | A single service system whose accounts nobody else needs |
| seneca-petition | Sign-ups that wait for someone to approve them, once it ships code | Nothing to install: the repository holds a README and a license, and npm has no package | You need an approval flow before it ships; write submit and decide messages over a petitions table |
| seneca-user | You want register, login, verification codes, and handle and email changes as ready-made Seneca messages | Iterated SHA-512 hashes, sessions with no expiry, a login reply carrying the stored hash, and the password forwarded to two more messages that debug logging records | Your account model or your password policy has to differ from the plugin’s |
Voxgig maintains Seneca, seneca-user and seneca-petition. This page compares them with Keycloak and with writing the messages yourself.
The plugin registers twenty-one message patterns, two of them the password hooks, and brings a schema of its own. Handles run 3 to 15 characters by default, the salt has a column of its own, and the hash is stored without the parameters that made it. Keycloak moves the whole problem out of your code, at the price of a server to operate. Writing the four messages yourself is the most code to own and the only option where every field is yours.
Define four account messages
The accounts plugin answers four patterns under sys:account. The password arrives in
register:account and login:account, and it is hashed or verified in that handler. No other
message ever carries it.
.message('register:account', async function register_account(msg: any) {
const handle = String(msg.handle ?? '').toLowerCase()
if (!/^[a-z0-9_]{3,32}$/.test(handle)) return { ok: false, why: 'bad-handle' }
if (typeof msg.password !== 'string' || msg.password.length < 12) return { ok: false, why: 'weak-password' }
if (msg.password.length > 1024) return { ok: false, why: 'bad-password' }
if (users.has(handle)) return { ok: false, why: 'handle-taken' }
const user = { id: randomUUID(), handle, hash: await argon2.hash(msg.password, HASH_OPTIONS) }
// Check again after the await: a second register for the same handle may have
// finished hashing first. A database does this with a unique index.
if (users.has(handle)) return { ok: false, why: 'handle-taken' }
users.set(handle, user)
return { ok: true, user: publicUser(user) }
})
.message('login:account', async function login_account(msg: any) {
const user = users.get(String(msg.handle ?? '').toLowerCase())
const valid = await argon2.verify(user?.hash ?? (await decoy), String(msg.password ?? ''))
if (!user || !valid) return { ok: false, why: 'bad-credentials' }
const token = randomBytes(32).toString('base64url')
// Only a digest of the token is stored, so a copy of this table opens no sessions.
sessions.set(digest(token), { user: user.handle, expires: now() + ttl })
return { ok: true, token, user: publicUser(user) }
})
HASH_OPTIONS is argon2id with 19 MiB of memory, two iterations and one lane, the minimum the
OWASP Password Storage Cheat Sheet
gives.1 An unknown handle is verified against a decoy hash, so it does the same argon2 work and
answers with the same why as a wrong password. Another service asks who its caller is with one message.
node demo.ts
register {"ok":true,"user":{"id":"0813dd8a-0ad1-4520-a748-10aaa26693ba","handle":"ada"}}
register again {"ok":false,"why":"handle-taken"}
login, wrong password {"ok":false,"why":"bad-credentials"}
login, unknown handle {"ok":false,"why":"bad-credentials"}
login {"ok":true,"user":{"id":"0813dd8a-0ad1-4520-a748-10aaa26693ba","handle":"ada"},"token":"<43 chars>"}
check session {"ok":true,"user":{"id":"0813dd8a-0ad1-4520-a748-10aaa26693ba","handle":"ada"}}
logout {"ok":true}
check session again {"ok":false,"why":"no-session"}
stored hash prefix $argon2id$v=19$m=19456,p=1,t=2
The stored hash carries its own parameters. Raise them later and argon2’s needsRehash returns
true for every hash made with the old ones, so each login can replace the hash it has verified.
Count the log lines that carry the password
Seneca’s debug log writes an entry for each message going in and each reply coming out, with the message data in it, as the logging tutorial shows. One register and one login, logged at debug, into an array that is counted and never printed:
node pitfall.ts
@seneca/user, debug log
log lines: 118, carrying the password: 10
cmd:encrypt,hook:password,sys:user
cmd:pass,hook:password,sys:user
login:user,sys:user
register:user,sys:user
login reply user fields: active, handle, id, name, pass, salt
own accounts plugin, debug log
log lines: 48, carrying the password: 4
login:account,sys:account
register:account,sys:account
login reply user fields: handle, id
own accounts plugin, redacting logger
log lines: 48, carrying the password: 0
login reply user fields: handle, id
@seneca/user, redacting logger
log lines: 118, carrying the password: 0
login reply user fields: active, handle, id, name, pass, salt
seneca-user forwards the password to two internal messages, cmd:encrypt and cmd:pass, so it
appears ten times instead of four. Its login reply also carries pass and salt, the stored hash
and its salt, and any caller that logs replies records both. The source has a TODO saying as
much.2 The sample runs the plugin with its test option, which hashes in process with one
round instead of 11,111; without it the count is the same ten.
Redaction belongs where messages become text, in the logger.
export const SECRET = /^(pass|password|proposed|repeat|token|secret|.*_token)$/i
export type LogEntry = Record<string, unknown>
export function redact(entry: LogEntry): string {
// Seneca also writes a message into error text: a pattern nothing matches prints the
// whole message in the error. So the values of secret-named fields are collected
// first, then removed from every string in the entry, not only from their own field.
const secrets = collectSecrets(entry)
const scrub = (text: string) => secrets.reduce((out, secret) => out.split(secret).join('[redacted]'), text)
const seen = new WeakSet<object>()
return JSON.stringify(entry, (key, value) => {
if (SECRET.test(key) && value != null) return '[redacted]'
if (typeof value === 'string') return scrub(value)
if (value instanceof Error) return { name: value.name, message: scrub(value.message) }
if (value && typeof value === 'object') {
if (seen.has(value)) return '[circular]'
seen.add(value)
}
return value
})
}
Field names alone miss one path. When a message reaches a pattern nothing matches, such as a
misspelled login:acount, Seneca 3.38.0 prints the whole message into the error text. It logs that
error at level error, so the line appears at the default level, info, and at warn, not only
at debug. The redactor therefore collects the values of secret-named fields first and removes
them from every string in the entry. The error the sender gets back carries the same text, so a
caller that logs err.message needs the filter too.
Pass it as log: { level, logger } when you create the instance. token is on the list too: the
OWASP Logging Cheat Sheet
names session identifiers and access tokens beside passwords as values to mask. A tracer that
copies message fields into span attributes needs the same filter. The OpenTelemetry page on
handling sensitive data lists
the Collector processors that delete or rewrite attributes before export.
Check it worked
Six tests pin the behavior, and the last one is the leak check. It runs the same register, login,
session check, and misspelled login twice at log level all. Without redaction it asserts the
password is in the log, so the test is known to catch it. With redaction it asserts neither the
password nor the session token is. A redactor that matched field names alone failed it on the
misspelled login.
node --test accounts.test.ts
1..6
# tests 6
# suites 0
# pass 6
# fail 0
The other five cover the round trip and identical answers for a wrong password and an unknown
handle. They check expiry with an injected clock, and a stored hash matching
$argon2id$v=19$m=19456,p=1,t=2 that needsRehash flags once the parameters rise. Another sends
three concurrent registers for one handle, and exactly one succeeds.
When it goes wrong
npm ci stops in the argon2 install script with a node-gyp error. No prebuilt binary matched
the platform, so the package fell back to compiling from source, which needs node-gyp and a C
compiler. The package README
lists the systems its binaries are built on, Ubuntu 22.04 and Alpine 3.18 among them. Build on a base
image at or past those versions, or install the compiler.
A field you added is in the log. The redactor matches field names, so a message carrying
credentials.pin passes. Name secrets so the pattern catches them, or switch to an allow list of
fields you log.
Two accounts exist for one handle. Two registers raced across the hashing await. Check again
after hashing, and enforce it with a unique index in the database.
When not to do this
Do not adopt seneca-user where your password policy matters. It hashes with 11,111 rounds of
plain SHA-512 over the pepper, the password, and the salt. The OWASP cheat sheet asks 220,000
iterations of PBKDF2-HMAC-SHA512, the nearest standard construction, and lists argon2id first.
Logins made by seneca-user carry no expiry: auth:user accepts a token until logout:user marks
it inactive.
Do not wait on seneca-petition for an approval flow. Its repository describes a sys/petition
entity with a submit, approve and reject lifecycle, and holds no code.
Do not run Keycloak for one system’s accounts. The Docker quickstart starts a development server in one command, and the same page lists what production adds: a database such as PostgreSQL and TLS certificates of your own. Keycloak earns that when several applications share a login or federate with an identity provider.
Do not write your own if you need multi-factor login, password reset, and account recovery from the start. Each is a flow with its own failure modes.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, seneca 3.38.0, seneca-promisify 3.7.2, seneca-entity
28.1.0, @seneca/user 6.4.0 and argon2 0.45.1. Every output block is what the command preceding it
printed, run in the page’s code directory after npm ci. Keycloak was not run; its behavior is as
its documentation describes it.
Footnotes
-
Argon2 won the Password Hashing Competition, which ran from 2013 to 2015 and received 24 candidates. It became RFC 9106 in September 2021, six years after the competition named it, through a research group of the IRTF rather than the IETF. The 19 MiB in the OWASP minimum is written
m=19456in the hash string, because the RFC counts memory in units of 1024 bytes, and 19 times 1024 is 19456. ↩︎ Back to text -
The line in
login_user.jsreadsTODO: remove pass and salt from output, directly before the code that returns them. The plugin’s defaults favor repeated digits: 11111 hashing rounds, a result limit of 111, and, in the hashing process, a cleanup interval of 11111 milliseconds. ↩︎ Back to text