# Security model

URL: https://better-helpdesk.com/docs/operations/security/

What Better Helpdesk defends against, where it draws each line, and what it keeps in your database.



Better Helpdesk runs inside your app, so it inherits your app's front door: your TLS, your session cookies, your proxy. What it adds is a set of routes that strangers can reach, a webhook that mail relays post to, and tables that hold what your customers wrote. This page describes how each of those is guarded. Who a caller is, and how far the helpdesk believes them, is the subject of [Identity and trust](/docs/identity/trust).

## Requests from browsers [#requests-from-browsers]

The handler serves two very different audiences from one origin. The agent UI acts with an agent's session cookie, and must never be driven by another site. The widget acts for customers and visitors, and may legitimately be called from the other origins you list.

Every request that changes something is checked before it runs, apart from the three routes that carry their own proof (inbound mail, jobs and rating links):

* A POST must have a JSON body, sent as `application/json`. A plain HTML form cannot send that, and a script on another site cannot send it without a CORS preflight, which the handler does not grant to agent routes. So a page on another site cannot submit a request that rides an agent's cookie.
* When the browser names an `Origin`, it must be the origin of `adminUrl`, or, for `widget/*` routes, an origin listed in any inbox's `allowedOrigins`. Anything else is refused with a 403.

This is why `adminUrl` has to be the address your team actually opens. If it names another origin, the agent UI's own requests look foreign, and every change fails.

Cross-origin access is granted to the `widget/*` routes only, and without credentials. The origins come from every inbox's `allowedOrigins` taken together, so an origin listed on one inbox may call the widget routes for any inbox; whether a visitor may write to a given inbox is decided by that inbox's `public` flag, not by its origins. A widget on another origin therefore never sends your session cookie; it identifies a signed-in user with an identity token instead.

## Secrets and signatures [#secrets-and-signatures]

Two routes are not called by browsers and answer only to a bearer secret: the inbound route to `inboundWebhookSecret`, and the jobs route to `jobsSecret`. When the secret is not configured, the route refuses everyone. Secrets are compared in constant time.

Identity tokens are HS256 JWTs signed with `identityTokenSecret`, and expire. Every signed-in user on another origin holds one, so the secret must resist offline guessing; `buildHelpdesk` throws on a secret shorter than 32 bytes.

Rating links, which let a customer rate a resolved conversation from the reply email, are signed with a key the package generates and keeps in its own `setting` table, and expire after 30 days. A link opens a small page with a button, and only the button records the rating, so a mail scanner that follows every link rates nothing. The page refuses to be framed and sends no referrer, because its URL carries the signature.

## Rate limits [#rate-limits]

Anything a stranger can trigger costs something: a row, an email, perhaps a call to a language model. The helpdesk counts these per hour in its `rate_limit` table. Before a visitor's first message from the widget counts, it also passes a signed form token and a honeypot, and a captcha when you add one; [Keep bots out of the widget](/docs/widget/bot-protection) explains each.

| What                                                 | Limit per hour                   | Counted by                                              |
| ---------------------------------------------------- | -------------------------------- | ------------------------------------------------------- |
| Visitor writes: new conversations, messages, uploads | `anonymousRateLimit`, default 20 | Client address; an IPv6 address counts as its whole /64 |
| Signed-in customer writes                            | `customerRateLimit`, default 60  | Contact                                                 |
| Inbound mail from one address                        | 30                               | Sender address, signed and unsigned mail apart          |
| Inbound mail from one domain                         | 100                              | Sender domain, signed and unsigned mail apart           |
| Receipts to people who write in                      | 200                              | The whole helpdesk                                      |
| Agent notices of new conversations                   | 200                              | Your signed-in users apart from everyone else           |
| AI triage of new conversations                       | 100                              | Your signed-in users apart from everyone else           |

The first four limits refuse the request or the message. The last three refuse nothing: past them, the helpdesk stops sending receipts, agent notices or triage requests until the hour is over. [HTTP routes](/docs/reference/routes#limits) lists the same limits.

The client address comes from your proxy. By default the helpdesk takes the last hop of `X-Forwarded-For`, the one your own proxy appended, since everything left of it is the client's to forge. If your setup differs, `clientIp` tells the helpdesk where to look.

Inbound mail over its budget is treated according to whether it was signed. Signed mail is refused with a 429, so the relay can retry later. Unsigned mail is dropped quietly, because refusing it would send a bounce to whatever address its forged From names. A signed reply from someone already on the conversation spends only its own sender's budget, so a flood from other addresses at the same domain cannot cut a conversation off.

The visitor form also carries a hidden field that people never see. A submission that fills it is answered as if it succeeded, and nothing is stored.

## Inbound email [#inbound-email]

Mail reaches the helpdesk through a relay, and relays differ in what they check. So the helpdesk checks for itself, on the raw message, whether the From domain signed it: a DKIM signature that passes, is aligned with the single From address (relaxed alignment: the signing domain is the From domain or shares its organizational domain, so `news.example.com` can sign for `example.com`), covers the From header and signs the whole body. It does not trust an `Authentication-Results` header written by a server along the way. A message that passes is **verified**.

What follows from that:

* Only a verified message from someone already on a conversation is added to it. Anything else opens a new conversation, because the reference in a plus-address can be guessed.
* An automatic reply or a bounce that is not signed is discarded. One that is signed is kept as an internal note on the conversation it answers; it never opens a conversation or reopens one.
* A message is stored once. A relay that delivers it again is answered without a second copy.

The raw message may be up to 25 MiB, with headers up to 64 KiB. DNS lookups for DKIM keys use the system resolver unless you pass `dnsResolver`.

## Bounds on input [#bounds-on-input]

Every route validates its input before it acts. A JSON body is refused past 256 KiB. A message is at most 20,000 characters. A customer can add at most 20 attachments to a conversation and an agent 20 to a message, each within `maxAttachmentBytes` (10 MiB by default), of a few content types: PNG, JPEG, GIF and WebP images, PDF and plain text. An attachment that arrives by email in any other type is stored, but served as `application/octet-stream`. The context the widget captures on a question, bug report or feature request filed as a ticket, such as recent page errors, is shown to the customer before sending, and they can untick anything they do not want to share. Other messages, such as a chat or a sales request, show no such review, so the page address, the first page of the visit and the referring page go without their query string and fragment, which can carry a sign-in or reset token; only `utm_` parameters stay.

## AI [#ai]

When `ai` is configured, the helpdesk sends conversation text to your model for triage and drafts, as far as the team's [AI switches](/docs/ai/suggestions#choose-what-the-ai-does) allow. The prompts tell the model to treat the conversation as data, never as instructions. A draft is never sent on its own: an agent reads it, edits it and sends it. Links in a draft are shown with their full address, since the customer wrote what the model read, and the agent should see where every link goes before sending.

## What is stored, and for how long [#what-is-stored-and-for-how-long]

Better Helpdesk stores no passwords and no sessions. For each agent it keeps your user id, name, email and avatar. For customers it keeps contacts, companies, conversations, messages, the page context the widget sent, and the metadata of attachments; the files themselves are in your storage. A visitor token is stored only as a hash. The rate-limit counters hold client addresses, bucketed as above, and are deleted after a day.

Resolved conversations are deleted `retentionDays` after they were resolved, with their files, when your scheduled jobs run. Without `retentionDays` they are kept. For a request to erase someone, `helpdesk.deleteContact()` deletes one person, with their conversations, their files and what they wrote in other people's threads, and `helpdesk.deleteCompany()` deletes everything held for one of your organizations. Agents can delete a contact from the agent UI.

For the migrate and import commands, `DATABASE_SSL=true` encrypts the connection and verifies the server's certificate, and `DATABASE_SSL=no-verify` encrypts without verifying it, for a self-signed certificate. A `sslmode` in the connection string takes precedence over both.

## Blocked senders [#blocked-senders]

An agent can block a contact. From then on the helpdesk refuses that contact's messages from the widget, and refuses anyone, signed in or not, who writes from an address a blocked contact holds; the refusal looks like a 404, which tells the sender nothing. Inbound mail from a blocked address is dropped quietly. The blocked contact's conversations leave the inbox and its counts, and stay on their contact page. When two contacts are merged, a block on either one holds for the merged person. [Block a sender](/docs/inbox/blocking) describes it from the agent's side.

## Reporting a vulnerability [#reporting-a-vulnerability]

Please do not open a public issue. Report it privately through [GitHub's security advisories](https://github.com/better-helpdesk/better-helpdesk/security/advisories/new) for the repository.
