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).
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:
henri generate authenticationThat 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.
The account flows
Section titled “The account flows”{ "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 ofhenri.accounts.PURPOSE, never a fourth string) and the path of a link (urlFor, which begins with a/) are all values you chose, soHENRI_ARGUMENT_INVALIDnames 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)andconfirm(token)take whatever followed the link: anything that is not a token isreason: 'malformed', which is what an expired, a spent and a forged one all answer, and anything that is not a password isreason: 'password'.requestEmailChangeanswers{ 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.
Registration
Section titled “Registration”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
201and{ user }, the public user. - Browsers are redirected to
signup.after(/by default), signed in. - A refused signup answers
422with{ data: { errors } }, or redirects a browser back tosignup.pathwith the messages in the flash. They reach the next page aserrors, keyed by field, and what was typed (minus the password) asflash.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.
The password reset
Section titled “The password reset”Three endpoints, and two pages of yours: the one asking for an address, and the one asking for a new password.
POST /password/forgottakesemailand answers202with 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, with422.- 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 aRefereror the browser history. The response carriesReferrer-Policy: no-referrerandCache-Control: no-store. An expired or spent link redirects to<path>/forgotwith a flash, or answers400to an API client. POST /password/resettakes 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 }}Email confirmation
Section titled “Email confirmation”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 stampsconfirmedAt.POST /confirmmails it again, with the same indistinguishable answer as a reset request. POST /account/emailasks for a change: it takes the new address and, unlessrequirePasswordisfalse, 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: truekeeps an unconfirmed account out of a session:POST /loginanswers403with{ 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.
The tokens
Section titled “The tokens”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.
The mails
Section titled “The mails”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(andreset.text.hbs) to change one view; - write
app/mailers/auth.jsto change the subjects, the sender or the data — an action you leave out keeps henri’s; henri generate authenticationwrites 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.
Passwords
Section titled “Passwords”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.
The policy
Section titled “The policy”| 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 } }}The pepper
Section titled “The pepper”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.
previousdoes the same for a rotation. Once no unpeppered hashes are left, setallowUnpeppered: 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.
Bound password hashes
Section titled “Bound password hashes”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: falseends 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_PEPPERset.
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 throwsHanding 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.
What it does and does not buy
Section titled “What it does and does not buy”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.
Sign-in lockout
Section titled “Sign-in lockout”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.
Login and logout
Section titled “Login and logout”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:401with{ statusCode, error, message }, or400when a field is missing. - Browsers asking for HTML are redirected to
config.user.afterLogin(/by default); on failure to<loginPath>?error=invalid(/login?error=invalidby 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.
The merge rule
Section titled “The merge rule”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_verifieddoes 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 address the provider did not verify
Section titled “An address the provider did not verify”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 } }Two providers claiming one address
Section titled “Two providers claiming one address”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.
What protects the callback
Section titled “What protects the callback”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;GETanswers405withAllow: 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 /signupand 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.
The identity table
Section titled “The identity table”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.
The rest of user.identities
Section titled “The rest of user.identities”| 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.
Sessions
Section titled “Sessions”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.
Where the request came from
Section titled “Where the request came from”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, ornone(typed, bookmarked, launched): allowed.same-siteorcross-site: theOriginmust be this application’s own origin or one you listed.evil.example.composting toapp.example.comis refused with403 Cross-origin request refused, valid token or not.- No fetch metadata at all (an older browser, a script): an
Originthat does not match is refused; noOriginfalls 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.
What leaves the server
Section titled “What leaves the server”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 }}Bearer tokens
Section titled “Bearer tokens”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 }).
