How-to › Run message-based services

How to add user accounts to a message-based service#

Give a Seneca system register, login and session-check messages with argon2id hashes, and keep the password out of every log line debug logging writes.

Audience
API producer
Level
intermediate
Topic
Build message-based services
Languages
TypeScript
Verified

Read first: Split a monolith into message-based services with Seneca

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A users table with argon2 and your own session messagesThe account model is yours to shape and the team can own security codeEvery edge case is yours: expiry, rate limits, rehashing, reset flows and the tests for eachYou need single sign-on, federation, or a security team’s audit trail you did not write
KeycloakSeveral applications share one login, or you need OpenID Connect, federation and an administration consoleA Java server and its database to run, patch and back up, and custom user fields through its user profile configurationA single service system whose accounts nobody else needs
seneca-petitionSign-ups that wait for someone to approve them, once it ships codeNothing to install: the repository holds a README and a license, and npm has no packageYou need an approval flow before it ships; write submit and decide messages over a petitions table
seneca-userYou want register, login, verification codes, and handle and email changes as ready-made Seneca messagesIterated 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 recordsYour 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.

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

  1. 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=19456 in the hash string, because the RFC counts memory in units of 1024 bytes, and 19 times 1024 is 19456. ↩︎ Back to text

  2. The line in login_user.js reads TODO: 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.