Masking โ
Introduction โ
Audit logs are a detailed record of what happened in your system โ which means they can easily capture sensitive data submitted by users: passwords, tokens, credit card numbers, and more. @actinode/express-activitylog masks these fields before they are written to your database.
Masking applies in two places:
- Middleware โ applied to
req.bodyon every logged request - Fluent builder โ applied to properties set via
.withProperties()when.mask()is called
WARNING
Audit logs are often retained for extended periods and may be readable by more people than your main database. Treat them as a lower-trust store and be conservative about what you log.
Default masked fields โ
The following fields are masked automatically โ no configuration required:
password ยท passwordConfirmation ยท token ยท accessToken ยท refreshToken
secret ยท apiKey ยท creditCard ยท cardNumber ยท cvv ยท ssnIf a request body contains any of these keys they will be replaced with ***masked*** before the log entry is saved.
// Request body: { email: 'alice@example.com', password: 'hunter2' }
// Logged properties: { email: 'alice@example.com', password: '***masked***' }
app.use(activityLog({ adapter })) // no mask config neededTIP
Default fields are merged with any custom denyList config โ you can never accidentally un-mask them by adding your own list.
denyList configuration โ
Mask specific named fields. Every other field passes through unchanged.
import { activityLog, prismaAdapter } from '@actinode/express-activitylog'
app.use(
activityLog({
adapter,
mask: {
denyList: ['internalCode', 'promoCode'],
},
}),
)// Request body: { name: 'Alice', internalCode: 'INT-007', promoCode: 'SAVE20' }
// Logged: { name: 'Alice', internalCode: '***masked***', promoCode: '***masked***' }TIP
Always add password and token fields to your denyList as a minimum security measure โ even though they are included by default, being explicit documents intent for future maintainers.
allowList configuration โ
Declare which fields are safe to log. Everything not on the list is masked.
app.use(
activityLog({
adapter,
mask: {
allowList: ['name', 'email', 'role'],
},
}),
)// Request body: { name: 'Alice', email: 'a@b.com', role: 'admin', ssn: '123-45' }
// Logged: { name: 'Alice', email: 'a@b.com', role: 'admin', ssn: '***masked***' }WARNING
Using allowList with an empty array will mask all properties. Make sure your allowList is complete before deploying.
Custom replacement string โ
Replace the default ***masked*** token with any string.
app.use(
activityLog({
adapter,
mask: {
denyList: ['password'],
replacement: '[REDACTED]',
},
}),
)
// Logged: { password: '[REDACTED]' }TIP
Keep replacement consistent across your app so log consumers always know what a masked value looks like.
Deep masking โ
By default, masking recurses into nested objects and arrays. Set deep: false to restrict masking to the top level only.
Nested objects (default deep: true):
// Request body:
// { user: { name: 'Alice', password: 'secret' } }
app.use(activityLog({ adapter }))
// Logged:
// { user: { name: 'Alice', password: '***masked***' } }Arrays of objects:
// Request body:
// { users: [{ name: 'A', token: 't1' }, { name: 'B', token: 't2' }] }
// Logged:
// { users: [{ name: 'A', token: '***masked***' }, { name: 'B', token: '***masked***' }] }Shallow only (deep: false):
app.use(
activityLog({
adapter,
mask: { deep: false },
}),
)
// Request body: { user: { name: 'Alice', password: 'secret' } }
// Logged: { user: { name: 'Alice', password: 'secret' } }
// โ nested object not recursed โ password survivesPer-model masking โ
Apply different masking rules depending on which model is being logged. Model keys match the class constructor name of the causer or subject.
app.use(
activityLog({
adapter,
mask: {
models: {
User: {
denyList: ['password', 'ssn'],
allowList: ['name', 'email'],
},
Payment: {
denyList: ['cardNumber', 'cvv'],
},
},
},
}),
)When the causer is a User instance, the User model config is used instead of the global config. For any model not listed, the global config applies.
TIP
Model keys are matched by class constructor name. class User {} โ key User. Plain objects use Object as the key.
Fluent API: .mask() โ
Mask specific fields on a single manual log entry without touching the global middleware config.
import { activity } from '@actinode/express-activitylog'
await activity(adapter)
.by(user)
.on(post)
.withProperties({ title: 'Hello World', secret: 'draft-key' })
.mask(['secret'])
.log('created post')
// Saved properties: { title: 'Hello World', secret: '***masked***' }.mask() accepts an array of field names and applies them as a denyList. Call it after .withProperties() and before .log().
TIP
.mask() supports field-level denyList only. For allowList, per-model config, or a custom replacement, use the mask option on activityLog() instead.
Combining denyList and allowList โ
denyList and allowList can be used together. The denyList is applied first, then the allowList.
app.use(
activityLog({
adapter,
mask: {
denyList: ['ssn'], // 1. mask ssn
allowList: ['name', 'email', 'role'], // 2. mask anything not in this list
},
}),
)
// Request body: { name: 'Alice', email: 'a@b.com', role: 'admin', ssn: '123', extra: 'x' }
// Logged: { name: 'Alice', email: 'a@b.com', role: 'admin', ssn: '***masked***', extra: '***masked***' }Security best practices โ
- Use defaults as a floor. The 11 default fields are a minimum โ add your own domain-specific fields on top.
- Prefer
allowListin high-security contexts. An allowList is safer because new fields are masked by default rather than exposed. - Review your mask config when adding new models. A new field on a model might contain sensitive data that your existing
denyListdoesn't cover. - Never log raw request bodies without masking in production. Always set up at least the default config before going live.
- Use
deep: true(the default). Shallow masking misses sensitive data in nested structures.
WARNING
Do not store unmasked copies of sensitive fields as a "just in case" fallback. If a value must be retrievable, use a dedicated secrets store โ not your activity log.
