# 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.

Source: https://voxgig.com/howto/add-user-accounts-to-a-message-service

- Audience: api-producer
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://github.com/ranisalt/node-argon2) | 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](https://www.keycloak.org/getting-started/getting-started-docker) | 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](https://www.keycloak.org/docs/latest/server_admin/index.html#user-profile) configuration | A single service system whose accounts nobody else needs |
| [seneca-petition](https://github.com/senecajs/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](https://github.com/senecajs/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.

```ts title="accounts.ts"
    .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](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
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.

```bash
node demo.ts
```

```text output
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](https://senecajs.org/docs/tutorials/logging-with-seneca.html)
shows. One register and one login, logged at debug, into an array that is counted and never
printed:

```bash
node pitfall.ts
```

```text output
@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.

```ts title="redact.ts"
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](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)
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](https://opentelemetry.io/docs/security/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.

```bash
node --test accounts.test.ts
```

```text output
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](https://github.com/ranisalt/node-argon2)
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](https://www.keycloak.org/getting-started/getting-started-docker) 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

- [Document Seneca plugin message patterns inside the repo](/howto/document-seneca-plugin-message-patterns)

- [Run one service locally and at the edge without forking it](/howto/run-one-service-locally-and-at-the-edge)

- [Split a monolith into message-based services with Seneca](/howto/split-a-monolith-into-message-based-services)

## 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.

[^1]: Argon2 won the [Password Hashing Competition](https://www.password-hashing.net/), which ran
from 2013 to 2015 and received 24 candidates. It became
[RFC 9106](https://www.rfc-editor.org/rfc/rfc9106.html) 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.

[^2]: The line in
[`login_user.js`](https://github.com/senecajs/seneca-user/blob/v6.4.0/lib/login_user.js) reads
`TODO: remove pass and salt from output`, directly before the code that returns them. The
plugin's [defaults](https://github.com/senecajs/seneca-user/blob/v6.4.0/user.js) favor repeated
digits: 11111 hashing rounds, a result limit of 111, and, in the
[hashing process](https://github.com/senecajs/seneca-user/blob/v6.4.0/lib/hasher.js), a cleanup
interval of 11111 milliseconds.