Skip to content

Users

Add a model named after the user configuration key (user by default, so app/models/User.js) and henri turns authentication on: the model gets email, password, roles, confirmedAt and passwordChangedAt, the server gets a session, POST /login, POST /logout and a CSRF token. None of this loads without a user model; with one, the configuration must provide a secret (HENRI_SECRET in .env).

app/models/User.js
module.exports = {
options: { timestamps: true },
schema: {
name: { type: 'string' },
},
};

Registration, the password reset and the address confirmation are three more endpoints, mounted when config.user asks for them. The fastest way to all of it:

Terminal window
henri generate authentication

That writes the user model (when there is none), the pages, the controller rendering them, an overridable copy of the mail templates and a test suite, and turns the three blocks below on. It also writes the sign-in buttons and the account page of the identity providers, which render whatever the configuration names — nothing, until it names one. The rest of this page is what it wired.

{
"user": {
"model": "user",
"signup": { "fields": ["name"] },
"passwordReset": true,
"confirmation": { "required": true }
}
}

Each block is true for the defaults, false (or absent) to leave the endpoints unmounted, or an object of settings. The endpoints they mount:

Endpoint Block What it does
POST /signup signup Creates an account and opens a session
POST /password/forgot passwordReset Mails a reset link; says nothing about the address
GET /password/reset/:token passwordReset Checks the link and moves the token into the session
POST /password/reset passwordReset Changes the password and retires the other sessions
GET /confirm/:token confirmation Confirms an address, or applies an address change
POST /confirm confirmation Mails the confirmation again; says nothing either
POST /account/email confirmation Asks to change the address of the signed-in account

Each one answers JSON to API clients and redirects browsers, the way POST /login does. They run ahead of your routes, so nothing in config/routes.js has to declare them; the pages the forms live on are yours.

Everything they do is also a method on henri.accounts, so a controller that wants to own the answers can call the same code instead of reimplementing it:

const created = await henri.accounts.register(
req.permit('email', 'password', 'name')
);
if (!created.ok) {
return res.boom.badData('the account could not be created', {
errors: created.errors,
});
}

What the service refuses, and what it answers

Section titled “What the service refuses, and what it answers”

These methods sit on an authentication path, where a refusal that says too much is an account-enumeration oracle. So the check on their arguments is drawn by whose mistake it is:

  • Yours is loud. A record (register, sendReset, sendConfirmation, allowed, identify, requestEmailChange, tokenFor), a token purpose (tokenFor, consume — one of henri.accounts.PURPOSE, never a fourth string) and the path of a link (urlFor, which begins with a /) are all values you chose, so HENRI_ARGUMENT_INVALID names what is wrong. Each of those used to answer something plausible instead: a link built onto the host with no slash between, a token no endpoint could ever spend, { email: 'could not be changed' } for a wrong user, and a gate answering yes about nobody.
  • A visitor’s is the answer it always was. resetPassword(token, password) and confirm(token) take whatever followed the link: anything that is not a token is reason: 'malformed', which is what an expired, a spent and a forged one all answer, and anything that is not a password is reason: 'password'. requestEmailChange answers { errors: { email } } for an address that is not one, because it has a form to put the message on.

requestPasswordReset(email) and requestConfirmation(email) are the pair in between: they answer Promise<void>, so they have nowhere to say “that is not an address” and refuse a value that is not a string. The endpoints in front of them already answer 422 for an address that is not one — with the same loose test the store validates the column with, never a stricter one, because a stricter one would refuse an address that is nonetheless in the database.

POST /signup takes email, password and the attributes config.user.signup.fields lists. Nothing else is read: roles, confirmedAt and passwordChangedAt are never assignable, whatever a form sends, and the store hashes the password on the way in.

  • API clients get 201 and { user }, the public user.
  • Browsers are redirected to signup.after (/ by default), signed in.
  • A refused signup answers 422 with { data: { errors } }, or redirects a browser back to signup.path with the messages in the flash. They reach the next page as errors, keyed by field, and what was typed (minus the password) as flash.values[0].
{
"signup": {
"path": "/signup",
"fields": ["name"],
"after": "/",
"login": true
}
}

The password goes through the policy, the same one a reset applies. Read its minimum from henri.accounts.policy().minLength (or henri.user.passwordPolicy) rather than hard-coding a number, so a page and its tests follow the configuration.

Registration is the one flow that says whether an address is registered, because a signup form has to. The other two never do.

Three endpoints, and two pages of yours: the one asking for an address, and the one asking for a new password.

  1. POST /password/forgot takes email and answers 202 with the same sentence whether or not the address has an account. henri writes that answer before it looks anything up: the lookup, the token and the mail happen afterwards, so nothing a client can time says whether the account exists either. Only a syntactically invalid address is refused, with 422.
  2. The mail carries GET /password/reset/:token. Following it checks the link, puts the token in the session and redirects to <path>/reset — so the token leaves the url on the first hop and cannot leak through a Referer or the browser history. The response carries Referrer-Policy: no-referrer and Cache-Control: no-store. An expired or spent link redirects to <path>/forgot with a flash, or answers 400 to an API client.
  3. POST /password/reset takes the new password (and the token, when the caller is not a browser holding the session). It changes the password, signs the account in, and every other session of that account stops working — which matters, because the usual reason someone resets a password is believing that somebody else has it.
{
"passwordReset": {
"path": "/password",
"expiresIn": "1h",
"after": "/",
"login": true
}
}

confirmation gives a new account a link to prove it can read its address, and gives an existing one a way to change that address.

  • Registering mails GET /confirm/:token; following it stamps confirmedAt. POST /confirm mails it again, with the same indistinguishable answer as a reset request.
  • POST /account/email asks for a change: it takes the new address and, unless requirePassword is false, the current password. Nothing is written. A link goes to the new address, and the account keeps the address it has until that link is followed — an address nobody proved they can read never becomes the address of an account.
  • required: true keeps an unconfirmed account out of a session: POST /login answers 403 with { data: { reason: 'unconfirmed' } }, or redirects a browser to <loginPath>?error=unconfirmed.
{
"confirmation": {
"path": "/confirm",
"emailPath": "/account/email",
"expiresIn": "3d",
"after": "/",
"required": false,
"requirePassword": true
}
}

Turning required on in an application that already has users locks them out until they confirm: confirmedAt is null on every row written before the column existed. Backfill it (UPDATE users SET confirmed_at = now()) before flipping the switch.

Nothing token-shaped is stored. A link carries a signed token holding three things, all covered by one HMAC over the application secret:

  • its purpose, so a confirmation link can never be replayed as a password reset;
  • its expiry, so an old link stops working on its own and nothing has to expire it;
  • a seed, the fingerprint of the state the action is about to change: the password hash for a reset, the address and its confirmation date for a confirmation. Performing the action moves the seed, and every token minted against the old one stops verifying.

That is what makes a link single use, and what makes a successful reset invalidate the links that were still in flight. A database leak hands over no working link, because forging one needs the secret, which is in the environment or in the encrypted credentials rather than in the database.

The other side of that coin: rotating secret invalidates every outstanding link. Anyone who asked for a reset before the rotation has to ask again. Sessions go with it (they are signed with the same secret), so it is rarely a surprise, but it is worth knowing before rotating one in production.

They come from the auth mailer, which ships with henri, so a fresh application can reset a password before anyone has written a template. Override as little or as much as you like:

  • write app/views/mailers/auth/reset.hbs (and reset.text.hbs) to change one view;
  • write app/mailers/auth.js to change the subjects, the sender or the data — an action you leave out keeps henri’s;
  • henri generate authentication writes both, which is the usual way in.

Each action receives the public user and the absolute url of the link. That url is built from config.url when there is one and from the running server’s own address otherwise, which is right in development and wrong behind a proxy: production applications set url. The messages are previewable on /_mailers in development like any other mailer, and delivery goes through deliverLater(), so the job queue takes them when the application has one and an SMTP timeout never blocks a request.

New passwords are hashed with argon2id when @node-rs/argon2 resolves (an optional dependency of @usehenri/core, installed with prebuilt binaries on every platform Node runs on) and with bcrypt at cost 12 otherwise. Both are always accepted on the way in, so an application that has been storing bcrypt hashes keeps working, and a hash below the current parameters is written again the next time its owner signs in — the way an application leaves an old algorithm behind without a migration and without asking anyone to reset anything.

henri.user.encrypt() refuses a password the policy does not accept, and the error surfaces as a rejected create(). The policy governs setting a password; signing in never applies it, so raising the minimum never locks out the people already there.

henri.user.validatePassword() is the same rule without the hashing, for a form that has to say what is wrong:

const { valid, errors } = henri.user.validatePassword(data.password);
if (!valid) {
// [{ code: 'too_short', message: 'must be at least 12 characters', minLength: 12 }]
return res.status(422).json({ errors: { password: errors[0].message } });
}

code is stable: missing, too_short or too_long. henri.user.passwordPolicy is { minLength, maxBytes, algorithm, ... }, so a page can show the rule before anyone submits.

Key Default
minLength 12 Shortest password accepted. Never below 8, whatever the configuration says.
maxBytes 72 Longest, in bytes. bcrypt silently ignores everything past 72 bytes, so henri refuses the password instead.
algorithm auto auto (argon2id when available, bcrypt otherwise), argon2id (fails the boot when it is not) or bcrypt.
bcryptRounds 12 bcrypt work factor. Never below 10.
memoryCost 19456 argon2id memory, in kibibytes (19 MiB), the OWASP first recommendation.
timeCost 2 argon2id iterations.
parallelism 1 argon2id lanes.
{
"user": {
"model": "user",
"password": { "minLength": 16 }
}
}

A pepper is a key mixed into every hash that lives outside the database, so a stolen table cannot be cracked offline and a hash cannot be forged for a password of the attacker’s choosing. It is off by default. Turn it on with HENRI_PASSWORD_PEPPER in .env, or config.user.password.pepper:

{
"user": {
"password": {
"pepper": {
"current": "...",
"previous": ["the key it replaced"],
"allowUnpeppered": true
}
}
}
}

Three things to understand before turning it on:

  • It is its own key, never config.secret. Rotating the session secret invalidates sessions and signed links, which applications do; it must never make a password unverifiable.
  • Losing it loses every peppered password. There is no recovery: the only way back is a password reset for everyone. Store it the way you store a database credential, and keep it out of the repository.
  • It arrives gradually. Hashes written before it existed keep verifying and are rewritten under the key as their owners sign in. previous does the same for a rotation. Once no unpeppered hashes are left, set allowUnpeppered: false — until then, someone who can write to the table can still plant an unpeppered hash of a password they know.

What a pepper does not do: the key is global, not per row, so on its own it would let someone who can write to the table copy a valid hash from one account onto another and sign in with the password they already knew. That is what the binding below is for.

A hash is a value, and a value can be moved. Someone who can write the database but does not have the pepper cannot forge a hash, so they do the next best thing: they take a hash whose password they know — their own account’s — and copy it onto somebody else’s row, or onto a row they invented.

henri folds the record’s externalId into what is hashed, keyed by the pepper, so a hash made for one row is arithmetically useless on any other. The pepper answers “you cannot make a hash”; the binding answers “you cannot move one”. It is on by default:

{
"user": {
"password": {
"binding": { "enabled": true, "allowUnbound": true }
}
}
}

A bound hash is stored in the same column with a marker in front of it, $henri-bound$v=1$, so verification knows which of the two to check and hashes exactly once. No schema change, no migration, and no extra cost per sign-in. The marker is not a secret: someone reading the table can see that a hash is bound, and that tells them nothing.

Three things to understand:

  • The identity is externalId, not the primary key. It is a uuid v7 the adapter generates before the insert, so it exists at the moment a password is first hashed, and it is immutable afterwards on all three adapters. A user model that opted out of it (options: { externalId: false }) cannot bind, keeps writing the hashes it wrote before, and henri says so at boot.
  • It arrives gradually, like the pepper. Every hash written before this exists is unbound and keeps verifying; each is written back bound the next time its owner signs in successfully. Nobody is asked to reset anything. The curve of “how many are bound” is the curve of “who has signed in since the upgrade” — so it never finishes on its own, and an account that never signs in again stays unbound forever. allowUnbound: false ends the migration by refusing whatever is left, which for a dormant account is indistinguishable from deleting it. Count first: SELECT count(*) FROM users WHERE password NOT LIKE '$henri-bound$%'.
  • Set a pepper. Without one the binding is unkeyed: it still stops a hash being copied, but someone who can write rows can recompute a bound hash for the row they are targeting. Binding is only forgery-resistant with HENRI_PASSWORD_PEPPER set.

Because a bound hash cannot be checked on its own, henri.user.compare() wants the user rather than its hash:

const user = await henri.user.findByEmail(email);
await henri.user.compare(password, user); // resolves true, or throws

Handing it a bound hash alone rejects with an error that says so, rather than answering “invalid credentials” to a password that is right.

It never resolves false: it rejects, and the four ways it can are told apart by their code and by nothing else — the message a mismatch carries is the one word it always was, so handing it to a client says exactly what it said before.

Code What happened
HENRI_USER_PASSWORD_MISMATCH The password is wrong — or there is no account (null), which is the same answer.
HENRI_USER_PASSWORD_UNVERIFIABLE There is a record and no hash on it, or a bound hash arrived alone.
HENRI_ARGUMENT_INVALID The second argument is not a user at all. See Wrong calls.

The first two rows are the ones worth reading twice. No account and a wrong password are deliberately the same answer, at the same cost: an address nobody has is checked against a hash bound to a uuid no row has, exactly the way POST /login does it, so your own sign-in endpoint cannot be timed to find out which addresses are registered. And req.user and findById() carry no hash — the password column is deselected on both — so passing one of those used to answer “invalid credentials” to the right password, for ever. It now says so. Load the account with findByEmail().

Setting a password needs to know which row it is for, which every ordinary write does — User.create(), user.save(), user.update(), User.findByIdAndUpdate(), User.bulkCreate(), insertMany(), and a Model.update() whose condition matches one row. A mass update that matches more than one row is refused with a validation error on password: one hash belongs to one record, and writing an unbound one instead would quietly reopen the door this closes. Give each account its own password, or turn binding.enabled off.

It stops an attacker with database write access relocating a hash: onto another user’s row, onto a row they inserted, or by restoring one row of an old backup over a newer one. The copied hash is bound to a uuid that is not the target’s, so sign-in fails.

It does not stop an attacker who can write anything. They can also write external_id, and setting the victim’s to the one their stolen hash is bound to makes it verify again. What makes that harder rather than impossible: external_id is unique, so the value has to be freed first by changing or deleting the row it came from — they cannot keep their own account and clone it, they have to damage a row, which is a visible event. The same attacker can strip the marker and write an unbound hash, which is what allowUnbound: false shuts.

And it does nothing at all about a stolen session, an application bug, or a compromised host. The pepper means write access is not enough to forge a hash; the binding means write access is not enough to move one. Neither is a substitute for the database not being writable by strangers.

Nothing caps how many attempts one account receives: the rate limit counts per address, so an attempt spread across many addresses against one account is unbounded. After config.user.lockout.max failures (10) inside windowMs (15 minutes) the account refuses sign-in attempts for the rest of the window, whoever is asking and whatever password they send — the check runs before the password is hashed, so a locked account costs nothing.

Failures are counted for whatever email was submitted, real or not, so 429 Too many failed sign-in attempts is not an account-enumeration oracle: an address nobody owns answers the same way. Browsers are sent to <loginPath>?error=locked. A successful sign-in clears the count.

{
"user": {
"lockout": { "max": 10, "windowMs": 900000 }
}
}

"lockout": false turns it off. The counter is in memory, so it is per process and clears on restart, like the rate limiter. It uses whatever config.rateLimit.store uses, so an application that already plugged a shared store (Redis, for instance) gets a lockout that holds across processes without saying so twice; lockout.store names a different one.

POST /login takes email and password, as JSON or as a form. The email is trimmed and lowercased before the lookup.

  • API clients (anything accepting JSON or */*) get { user } back, the public user. On failure: 401 with { statusCode, error, message }, or 400 when a field is missing.
  • Browsers asking for HTML are redirected to config.user.afterLogin (/ by default); on failure to <loginPath>?error=invalid (/login?error=invalid by default).

With config.user.confirmation.required, an account whose address is not confirmed is refused here even with the right password: 403 and { data: { reason: 'unconfirmed' } }, or a redirect to <loginPath>?error=unconfirmed. The lockout counter is cleared first, so trying a correct password while unconfirmed cannot lock an account out.

POST /logout destroys the session and answers { ok: true }, or redirects browsers to /. GET /logout is deprecated and answers 405.

Both are mounted before your routes, on every renderer. henri ships no login page: write one at loginPath (app/views/pages/login.js with the React renderer) that posts to /login, or let henri generate authentication write it. A plain HTML form works because a browser that has no session yet needs no CSRF token; from React, fetch({ route: '/login', method: 'post' }, { email, password }) followed by hydrate() does the same without leaving the page.

In a controller, req.user is the user instance (without its password) and req.isAuthenticated() tells whether someone is logged in. Views get the public user, see below.

Signing in with somebody else’s identity provider

Section titled “Signing in with somebody else’s identity provider”

config.user.identities mounts three endpoints and gives henri a table beside the user model:

{
"user": {
"identities": {
"providers": {
"acme": {
"authorizationUrl": "https://acme.example/oauth/authorize",
"tokenUrl": "https://acme.example/oauth/token",
"userinfoUrl": "https://acme.example/oauth/userinfo",
"clientId": "...",
"clientSecret": "...",
"scope": ["openid", "email"]
}
}
}
}
}

henri ships no provider list and no provider secrets. There is no github inside it and nothing to fill in for one: an application names its providers and points each at its own three endpoints, and the client secret belongs in the encrypted credentials (henri credentials:edit) or in the environment — henri audit reports one written in a config/*.json the way it reports an encryption key there.

Endpoint What it does
POST /auth/:provider Sends the browser to the provider. Signed in, it is a link instead. GET answers 405.
GET /auth/:provider/callback What the provider sends the browser back to. Register this url with the provider.
POST /auth/:provider/unlink Takes a link away. Refuses to take away the last way into an account.

henri.identities.redirectUri('acme') prints the url to register. It is built from config.url, never from the request, so a Host header a client chose cannot move it.

A callback comes back with a verified address, and that address already belongs to an account with a password. henri refuses. The callback answers reason: 'exists', no session is opened, nothing is written, and the person is told to sign in the way they already do and then link the provider from their account.

The alternative applications reach for — link automatically when the provider says the address is verified — is the way this feature goes wrong, and not by a little:

  • it lets a stranger change which credentials open an account. The owner did nothing, was asked nothing and saw nothing, and afterwards a second way in exists;
  • it collapses that account’s security to the weakest provider it can be linked from. The hashing, the lockout and the reset flow stop mattering the moment any provider that will assert the address is reachable, and nobody agreed to that trade;
  • email_verified does not mean what the auto-link needs it to mean. It is a provider’s belief that somebody could read a mailbox at some point, from a provider that may be self-hosted, may verify a custom domain an attacker controls, or may be an enterprise tenant allowed to claim a domain — and it is never a statement about who owns an account in your database;
  • half the providers do not send the claim at all, and reading “absent” as “verified” builds the takeover by accident. That is the most common way this is got wrong.

The third possibility — link only when the session already belongs to that user — is right, and it is not a setting because it is the flow. A callback started from a signed-in session is a link, always, and it is the only automatic link henri makes. The session is the consent, and it is consent the account owner gave with their own credentials a moment ago; the address is not even looked at there.

"merge": "verified" does what the wrong answer does. It is gated twice — the provider must also be marked "trusted": true, and henri audit reports the pair as a finding — and it has one honest use: a single-tenant application whose provider is its own corporate identity provider, where the provider genuinely is the authority on who owns an address.

An unverified address decides nothing:

  • the identity is keyed on the provider and the subject it issues, so somebody already linked signs in whatever the address says. The subject is the credential and the address never was;
  • a callback with no identity and no verified address answers reason: 'unverified' before it reads the user table, so a refusal for an address that has an account and one for an address that has none are the same answer at the same price. That is the rule the account flows keep and this flow does not get to be the exception;
  • a signed-in person may still link such a provider, because the session is the proof.

A provider that never verifies an address says so once, and becomes one you can link and cannot sign up with:

{ "claims": { "verified": false } }

They are two rows, because the key is the provider and its subject and not the address. The second one is the case above: it arrives, its address has an account, and it is refused — unless the person is signed in, in which case it is a link and both providers then open that account. Two different people at two providers claiming one address end the same way: whoever arrives first with a verified address gets the account (when signup is on), and the second is refused rather than merged into it.

A person holds at most one identity per provider, so unlink always names one row and an account cannot quietly grow a second way in at a provider it already has.

The flow leaves the origin and comes back, which is the one case the CSRF token cannot cover. OAuth already has the answer — the state parameter — and henri does not invent a second one.

  • Leaving is a POST, so it goes through the double-submit token and the origin check like every other unsafe request; GET answers 405 with Allow: POST. A third-party page therefore cannot start the flow in a visitor’s browser, which is what closes login CSRF: being signed into somebody else’s provider account, or having theirs linked to yours. This one route asks for the token even when the visitor holds no session cookie — the CSRF middleware waives it there, because there is normally no session to ride on, and a visitor about to sign in is exactly the person who has none yet.
  • The state is minted per attempt, kept in the session, single use and expiring. It is bound to the session cookie, so one minted in one browser cannot be spent in another; it is taken out of the session when it is read, so a callback url works exactly once; and it stops working after stateExpiresIn (10 minutes). The pending set is bounded.
  • PKCE (S256) is on. The verifier never leaves the server, so an authorization code observed in a Referer, a log or a shared browser is not enough to redeem.
  • A link is checked against the session that is there now, not only the one the state was minted in.
  • The lockout and the rate limit are the same ones. A locked account cannot be signed into through a provider either, or this would be a way around the lockout of POST /login, and a successful identity sign-in clears the count. A failed callback is deliberately not counted as a failed attempt: there is nothing to guess at a callback, so counting would only hand somebody a way to lock an address out. The auth rate limit covers every request under the identity path, the callback included.
  • The session identifier is new before the person is in it, the fixation defence POST /signup and the password reset already take.

henri never parses an id_token. If a token response carries one it is ignored, and the profile comes from userinfoUrl over a request henri makes itself with the access token — which is the same claims over a channel that is already authenticated, without JWKS fetching, key rotation and algorithm confusion. henri also ships no OAuth provider: being an authorization server is a different product.

henri_identities is a table henri owns, the way the job queue and the access trail own theirs: raw SQL through the adapter, or a MongoDB collection, and never a model. A row is a credential — whoever can write one can sign in as whoever it points at — and a model would put provider and subject behind an application’s own mass assignment, scaffold, routes and graphql: true. It also has to exist on a store with no models at all, so that sign-in with a provider needs no model file.

Reading them is henri.identities.forUser(user), which answers rows carrying the provider, when it was linked, the address the provider asserted, whether it said that address was verified, what the row is allowed to imply and how it came to be:

origin How it came to be
signup henri opened the account for this callback (the address belonged to nobody).
session Linked from a session that already belonged to that person.
verified The automatic merge, which only merge: "verified" allows.

allows is what a row may imply: signin opens a session, verify identifies the person and never opens one on its own, so it can only be linked from a session. It is written on the row at link time and read from the row afterwards — turning a provider from verify into signin does not promote the identities linked under the old rule.

An account henri opened from a callback has a password nobody knows: the column is NOT NULL on every adapter, so one is generated and handed to nobody. That means the identity is the only way in, and unlink refuses to take away the last one. POST /password/forgot is how such a person gets a password of their own, and the refusal lifts once they have one.

Identities are personal data: henri privacy:export lists the providers (never the subject, which is the credential) and henri privacy:erase deletes the rows rather than anonymizing them, because an anonymized credential still opens the account.

Key Default Description
providers {} The providers, by the name a url calls them. One is enough to turn the endpoints on.
merge refuse What a callback does with a verified address that already has an account. See above.
path /auth The prefix of the three endpoints.
after / Where a browser lands once a provider was linked.
signup true Open an account for a verified address that belongs to nobody. false refuses instead.
allowHttp false Reach a provider over plaintext http. Development only, and henri audit reports it.
stateExpiresIn 10m How long one attempt stays valid.
timeout 10s How long a provider has to answer.
table henri_identities Table (or collection) name. Letters, digits and underscores only.
enabled true false leaves the endpoints unmounted without removing the providers.

And of one provider:

Key Default Description
authorizationUrl Where a browser is sent. Required.
tokenUrl Where the authorization code is redeemed, server to server. Required.
userinfoUrl Where the person’s claims are read with the access token. Required.
clientId Required.
clientSecret Required. Its home is the credentials or the environment.
scope [] A list, or one space-separated string.
claims.subject sub The claim holding the provider’s identifier for a person.
claims.email email The address claim.
claims.verified email_verified The claim saying the address is verified, or false for a provider that never verifies one.
label the name What a button calls it.
allows signin signin or verify, see above.
auth basic basic (client_secret_basic) or post (client_secret_post).
pkce true false stops sending a code challenge, for a provider that refuses parameters it does not know.
trusted false This provider is an authority on who owns an address here. What merge: "verified" needs.
params {} Extra authorization parameters ({ "prompt": "select_account" }).

A provider that cannot be used as configured fails the boot (HENRI_IDENTITY_PROVIDER_INVALID), naming every provider and every problem at once, rather than mounting a button that would fail.

The session cookie, henri.sid, is httpOnly, SameSite=Lax, Secure in production, lives 30 days (config.user.sessionMaxAge, in milliseconds) and is only written once something is stored in it. Sessions are kept in the database of the user model’s store and survive model reloads.

The session remembers when it was opened. A password reset stamps passwordChangedAt on the account, and every session older than that stamp stops resolving to a user on its next request — that is how a reset signs the other devices out, with no scan of the session store and no extra read per request. An application that changes a password itself should do the same:

await user.update({ password, passwordChangedAt: new Date() });

Once a user model exists, every response carries a henri.csrf cookie (readable by scripts, SameSite=Lax). POST, PUT, PATCH and DELETE requests that send the session cookie must send that token back in the X-CSRF-Token header (X-XSRF-TOKEN is accepted as an alias) or a _csrf field, otherwise they get a 403 carrying HENRI_USER_CSRF_INVALID (or HENRI_USER_CSRF_ORIGIN_REFUSED when it is the origin check that refused; see the error codes). Requests without a session cookie and requests authenticated with a bearer token are exempt. One route asks anyway: starting a provider sign-in needs the token whatever the cookies say, because the exemption’s premise — there is no session to ride on — is not true of a person about to open one.

The token reaches the views as csrf: the React fetch() and hydrate() helpers and the Inertia fetch() helper add the header for you, the Inertia Form adds the _csrf field, and Inertia’s own visits echo the XSRF-TOKEN cookie its engine sets; add <input type="hidden" name="_csrf" value="{{@csrf}}"> to a Handlebars form. Set "csrf": false in the configuration to turn the check off.

The token alone does not survive everything: a sibling subdomain, or anything else that can write a cookie on the parent domain, can plant a token it knows and submit it. So the same requests — unsafe method, session cookie, no bearer token — must also come from somewhere the application recognizes, which browsers state in Sec-Fetch-Site and Origin:

  • Sec-Fetch-Site: same-origin, or none (typed, bookmarked, launched): allowed.
  • same-site or cross-site: the Origin must be this application’s own origin or one you listed. evil.example.com posting to app.example.com is refused with 403 Cross-origin request refused, valid token or not.
  • No fetch metadata at all (an older browser, a script): an Origin that does not match is refused; no Origin falls through to the token, which is what it always did.

The origin compared against is what the browser saw: behind a reverse proxy it comes from X-Forwarded-Host and X-Forwarded-Proto when config.trustProxy allows it, not from the internal Host the proxy rewrote.

Nothing here applies to a request that sends no session cookie, so a cross-origin API client keeps working untouched. A client that does send credentials cross-origin needs its origin trusted; whatever config.cors.origin already allows is trusted for you:

{
"csrf": {
"origin": true,
"trustedOrigins": ["https://admin.example.com"]
}
}

"csrf": { "origin": false } keeps the token check without this one.

Give a route an array of roles and only logged-in users owning every one of them reach it; see Routes. A new user gets config.baseRole; change roles with user.setRoles() or User.setRoles(id, roles), never through a form (mass assignment drops roles).

The paths sent to the views are filtered by the roles of the current user, so a page can show a link only when the user may follow it:

const { paths, pathFor } = useHenri();
{
paths.admin_users_path && (
<Link href={pathFor('admin_users_path')}>Admin</Link>
);
}

A role answers “may this kind of person reach this endpoint”. The question every application also has – may this person read this proposal – is answered by a policy, a file next to the model it is about. Policies compose with roles rather than replace them, and they filter paths and _links the same way, one level down.

Only the public representation of a user reaches views, req._henri.user and JSON answers: { externalId, email, roles } plus the fields listed in config.user.public. henri.user.publicUser(user) builds that object; use it whenever you send a user to a browser yourself. The identifier is the user’s public one; the primary key stays on the server, like every record’s (see Identifiers). A user model that opted out of it answers with id instead.

Nobody (null, or an anonymous req.user) answers null. Anything that is not a record is refused rather than serialized: the object it used to build carried an identifier that named no row, and that object goes to a view and to a JSON body.

{
"user": {
"model": "user",
"public": ["name", "avatar"],
"loginPath": "/login",
"afterLogin": "/",
"sessionMaxAge": 2592000000
}
}

A passport jwt strategy is registered on henri.passport with the application secret (it reads Authorization: Bearer <token> and loads the user from the id, _id or sub claim), but core applies it to no route and issues no token. To accept tokens on a route, add the strategy yourself with henri.addMiddleware() or in the controller: henri.passport.authenticate('jwt', { session: false }).