> ## Documentation Index
> Fetch the complete documentation index at: https://veriqa.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Security and data handling

> What Veriqa collects, where it stores it, how long it keeps it, what it encrypts and what leaves your perimeter.

This page is the address for the questions a security review asks first.

<Note>
  This is a factual description of the software, not legal advice and not a compliance
  certification. Which of these facts matter under your regulator, and what you have to do about
  them, is a decision for you and your counsel.
</Note>

## Who holds what

Veriqa is delivered as code you run. In the self-hosted model that is the whole answer to "where
does the data go":

* **You are the controller.** The database, the key material and the logs are on your
  infrastructure, under your connection strings. Nothing is provisioned for you.
* **Veriqa (the vendor) is neither controller nor processor of your users' data.** No user data
  reaches the Veriqa vendor: the product makes no calls home, ships no telemetry and has no vendor endpoint of any
  kind. The only outbound addresses in the code are the messenger APIs you configure and the SMTP
  server you point it at (see [What leaves your perimeter](#what-leaves-your-perimeter)).
* **The messenger providers are your counterparties, not the Veriqa vendor's.** Telegram, MAX and the WhatsApp
  Cloud API see the messages you send through your own bot credentials. Your relationship with each
  of them is direct.

## Personal data inventory

Everything the shipped product touches, and nothing beyond it.

| Data | Where it comes from | Where it lives | For how long |
| - | - | - | - |
| Channel user id (Telegram/MAX/WhatsApp id, email address) | the channel, on confirmation | transaction store; `sub` claim of the issued token | transaction: minutes. Token: your token lifetimes |
| Display name, first/last name, username | the channel profile | transaction store; `name`, `given_name`, `family_name`, `preferred_username` claims | as above |
| Email address, `email_verified` | the channel (or typed by the user in the Email channel) | transaction store; `email` claim | as above |
| Phone number | the channel, where it exposes one | transaction store; `phone_number` claim | as above |
| Avatar image | the channel profile; Telegram's is downloaded by the adapter (at most `AvatarDataUri.MaxImageBytes`, 64 KiB, so that the image fits the 128 KB transaction snapshot; a larger photo is not delivered, and the core drops a malformed or oversized value from any adapter, so the sign-in goes on without an avatar) and kept as a `data:` URI, MAX may give an `https` URL | transaction store (channel identity snapshot); `picture` claim, issued only to clients that request the `avatar` scope, inside the payload of their access and refresh tokens in the OpenIddict database | transaction: minutes. Token records: until pruned (see below) |
| Locale | the channel profile | transaction store; `locale` claim | as above |
| IP address of the initiator | the `/connect/authorize` request | initiator-context snapshot inside the transaction | transaction: minutes. Never displayed, never logged |
| Browser, OS, device type | the `User-Agent` of that request, parsed | initiator-context snapshot | as above. Shown in the confirmation message |
| Raw `User-Agent` string | the same header | initiator-context snapshot — **only if you turn it on** | as above |
| Country and city | offline GeoIP lookup of that IP | initiator-context snapshot | as above. Shown in the confirmation message as "City, Country" |
| Audit records | transaction and channel events | the audit sink you chose | 90 days by default; only when you enable the trail |
| Relying-party expectations (an email or phone the application says it expects) | your application, when it starts a transaction | transaction store, **encrypted** | transaction: minutes |
| Confirmed-operation parameters | your application | audit records — **only if you turn it on** | the audit retention period |
| Claims Veriqa collected during a sign-in (phone, email, form fields) and the `Optional` claims the user declined | the user, on the completion step | the collected claims store, by tenant and `sub` — **only if you turn it on** | until deleted by `sub`, or by the retention you set |

**Not in the list:** message bodies from the channel (a channel adapter reads what it
needs to identify the sender and confirm, and keeps nothing else) and any
long-lived profile of the user beyond the collected claims above. The product ships **no user
directory** — see
[Channel identities](#channel-identities-nothing-is-stored-by-default).

## Collection defaults

These are on out of the box. Nothing here needs an action from you to start; each needs one to stop.

| Setting | Ships as | What it means |
| - | - | - |
| `Veriqa:InitiatorContext:Enabled` | `true` | The initiator-context snapshot is built at all |
| `Veriqa:InitiatorContext:CollectIpAddress` | `true` | The initiator's IP is written into the snapshot |
| `Veriqa:InitiatorContext:CollectUserAgent` | `true` | The `User-Agent` is parsed into browser / OS / device type |
| `Veriqa:InitiatorContext:CollectGeoLocation` | `true` | Country and city are looked up **when a GeoIP database is available** |
| `Veriqa:InitiatorContext:StoreRawUserAgent` | `false` | The raw header is *not* stored; only the parsed fields are |

Two qualifications that matter more than the flags:

**Geolocation is on by default but inert by default.** The lookup needs the satellite package
`Veriqa.Core.AuthServer.MaxMind` *and* a local `.mmdb` database, and the base packages carry
neither. Without them the provider reports itself unavailable and the geo fields stay empty — so a
stock installation collects no location, even though the flag says `true`. Adding the database is
what starts the collection.

**The IP is collected but never shown and never logged.** It goes into the snapshot inside the
transaction and stops there: the confirmation message names only the application, browser, OS and
region, and the diagnostic log for an untrusted forwarding configuration records a result code
rather than the address.

Turning all of it off is one section:

```json appsettings.json theme={null}
{
  "Veriqa": {
    "InitiatorContext": {
      "Enabled": false
    }
  }
}
```

With `Enabled: false` no snapshot is built, and the confirmation message simply carries no context
line. You can also keep the context and narrow it — drop `region` from `DisplayFields`, or set
`CollectIpAddress: false` (which also removes geolocation, since the lookup has no input). Details
and the per-application narrowing are in [Initiator context](/docs/concepts/initiator-context).

## Where it is stored

| Store | What it holds | Configured by |
| - | - | - |
| Transaction store | the whole live transaction: OIDC request context, the channel identity snapshot, the resolved identity, the initiator context | `ConfigureTransactionEngine` — InMemory, EF Core or Redis ([Storage](/docs/guides/storage)) |
| OpenIddict database | OIDC clients, authorizations and tokens; the subject of a token is `{channel}:{channel_user_id}`. Access and refresh tokens are reference tokens: the client holds an identifier, and the full token — every claim it carries, `picture` included for clients with `avatar` — is kept in the token record's payload | `Veriqa:OpenIddict:Database` |
| Audit sink | audit records, if you enabled the trail | `AddVeriqaAuditTrail` — in-memory, EF Core, or an implementation of yours |
| Channel stores | Email action tokens (transaction id + normalized email), Email push correlations (transaction id only), prompt coordinates, processed-message ids | in-process by default; Redis for multiple replicas ([Storage](/docs/guides/storage)) |
| Collected claims store | claims Veriqa collected and declined `Optional` claims, by tenant and `sub`, if you turned it on | `Veriqa:CollectedClaims:Store:Enabled` or `UseEfCoreCollectedClaimsStore` ([Storage](/docs/guides/storage#collected-claims-store)) |
| DataProtection key ring | the keys protecting the relying-party expectations | `Veriqa:DataProtection:RedisConnectionString` or `:KeysDirectory` |

### Channel identities: nothing is stored by default

The port that would persist a channel identity — channel user id, phone, email, display name, as an
upsert with neither TTL nor deletion — is `IChannelIdentityRepository`, and **Veriqa ships no
implementation of it and registers none.** Sign-in works without one: the resolved identity travels
with the transaction and is gone with it. Keeping that data indefinitely is an act you perform
deliberately, by registering an implementation of your own; see
[Storage](/docs/guides/storage#channel-identities-nothing-is-stored-out-of-the-box).

## Retention

| What | Ships as | Where the number comes from |
| - | - | - |
| A live transaction | 300 s to confirm | `Veriqa:TransactionEngine:TransactionTtlSeconds` (60–1800) |
| A dead transaction (completed, failed, expired) | readable for a further 600 s, then deleted | `CompletedRetentionSeconds` (60–3600); the sweep runs every `CleanupIntervalSeconds` (60 s) |
| Audit records | 90 days | `Veriqa:Logging:RetentionDays`; the sweep runs hourly in batches of 500 |
| Email action tokens, push correlations, prompt coordinates | until their own `ExpiresAt` | the entity itself |
| Processed-message records (inbound mail dedup) | 24 hours | `ProcessedMessageRetention` |
| Access / refresh tokens, authorization codes | valid for 1 h / 14 d / 5 min; the record is deleted once it is no longer valid and more than 24 h old | `Veriqa:OpenIddict:Server:*LifetimeSeconds`; deletion — `TokenPruneThresholdSeconds`, checked every `TokenPruneIntervalSeconds` (1 h) |
| Channel identities, if you register the port | **forever** | the contract has no TTL and no deletion — it is yours to bound |
| Collected claims, if you turn the store on | **forever** unless you set a retention | `Veriqa:ClaimCompletion:CollectedClaims:RetentionDays`, counted from the last sign-in that read them; deletion by `sub` — [Storage](/docs/guides/storage#deleting-by-sub) |

In short: **the transaction store empties itself within about fifteen minutes** of a
sign-in, and the audit trail is the only Veriqa store that keeps anything for days. Everything with
an indefinite lifetime is something you added — a channel identity repository, the collected claims
store, or tokens with a lifetime you chose.

<Warning>
  **The OpenIddict database is pruned by the auth server.** A background job deletes token and
  authorization records that are no longer valid (expired or revoked) and were created more than
  `Veriqa:OpenIddict:Server:TokenPruneThresholdSeconds` ago (24 h by default), every
  `TokenPruneIntervalSeconds` (1 h), on every store provider. Valid records are never deleted.
  Until a record is pruned, its payload — including an avatar image for clients with `avatar` — stays
  in the database, so a lower threshold shortens how long that data is kept.
</Warning>

### The audit trail is off until you ask for it, twice

Two independent switches, and both are needed:

1. The satellite is opt-in — a host that never calls `AddVeriqaAuditTrail` has no trail at all.
2. `Veriqa:Logging:Mode` ships as `System`, and records are written **only** in `Audit`. `Disabled`
   and `System` are the same thing to the receiver: write nothing.

Retention, once records exist, is not a third switch: the sweep is registered automatically for
either built-in sink and deletes anything older than `RetentionDays`. `ConfigureRetention` only
tunes how often the sweep runs and how large its batches are — it does not turn retention on, and
skipping the call does not leave records unbounded. A retention of zero or less is refused at
startup rather than read as "keep nothing".

The one case where nothing sweeps: **a custom sink you registered with `UseSink<TSink>()`.** Records
then live in your store, the built-in sweep is not registered, and the host says so at startup.
Bounding that store is yours.

```json appsettings.json theme={null}
{
  "Veriqa": {
    "Logging": {
      "Mode": "Audit",
      "RetentionDays": 90
    }
  }
}
```

### What an audit record actually contains

A closed set of attributes, fixed in the schema — one column per attribute, no free-form payload:
the timestamp, the actor, the action code, the target (the transaction id), the outcome and its
reason code, plus the transaction type, channel type, correlation id, tenant, `client_id`, UI locale
and time zone, the channel's own safe details and its declared inbound verification level.

**The actor is masked.** For an action the user performed in a channel it is
`{channel}:{masked id}` — at most the last two characters of the channel user id stay readable. The
unmasked value never leaves the transaction store.

**No IP, no User-Agent, no geolocation, no display name, no email** reaches an audit record. The
initiator context lives in the transaction and dies with it.

The single exception you can switch on is the parameters of the confirmed operation — the declared
action kind and its slot values. Those are your application's subject data and may be anything, so
they are off by default and copied into the record only when you ask
([Hardening](/docs/guides/hardening#logging-and-audit)).

## Encryption

| What | Encrypted by Veriqa? |
| - | - |
| Relying-party expectations (the email/phone your application says it expects) | **Yes** — ASP.NET Core Data Protection, an isolated purpose string, values readable across key rotation while the old key is in the ring |
| Everything else in the transaction store — identity snapshots, initiator context, OIDC request context | **No.** Written as ordinary columns / Redis values |
| Audit records | **No** |
| OpenIddict tokens | Access and refresh tokens reach the client as opaque identifiers; the token kept in the record payload of the OpenIddict database is encrypted by OpenIddict with your encryption certificate. The `id_token` is signed, not encrypted |
| The database files, the Redis dump, the backups | **No** — that is your storage layer: transparent encryption, volume encryption and an encrypted DB connection are set up by your operator |

There is no application-level encryption of stored personal data beyond that one protected field,
and no key management of its own. If your threat model requires personal data encrypted at rest,
that requirement is met at the storage layer you operate — encrypted volumes, TDE, an encrypted
connection to the database — not by a setting in Veriqa.

Secrets — `client_secret`, bot tokens, SMTP credentials — belong in environment variables or a
secret store, never in config files in the repository. Passwords are never written to a log: where
one has to be compared, the comparison is over a SHA-256 hash.

## Keys

Three key materials, three different homes.

**Token signing and encryption certificates.** In `Development` the product uses OpenIddict's
development certificates. Outside `Development` it loads X.509 certificates you supply, by path or
base64 (`Veriqa:OpenIddict:Server:SigningCertificate*` / `EncryptionCertificate*`), and refuses to
start without them.

<Warning>
  **Replacing a certificate is a session-invalidating operation.** The configuration accepts exactly
  one signing and one encryption certificate, so JWKS carries a single signing key and old and new
  never overlap. Tokens signed with the previous key stop validating the moment you swap it. Plan
  key replacement as a maintenance event, not as a background rotation — the same warning appears in
  [hardening](/docs/guides/hardening#issuer-and-certificates).
</Warning>

**The DataProtection key ring.** Priority is Redis
(`Veriqa:DataProtection:RedisConnectionString`), then a directory
(`Veriqa:DataProtection:KeysDirectory`); **with neither set, the keys live in memory and are lost on
restart.** That is fine for development and wrong everywhere else — after a restart the protected
expectations of live transactions can no longer be read. Data Protection rotates its own keys, and
values written under an earlier key stay readable while that key is still in the ring, so a ring on
durable storage makes rotation a non-event.

**Channel and application secrets** — bot tokens, webhook secret tokens, `client_secret`, SMTP
credentials. Rotating them is a channel-side and client-side operation; nothing in Veriqa caches
them past a configuration reload.

## What leaves your perimeter

Only what you configured a channel for. Per confirmation, the outbound message carries:

* the **name of the application** that started the sign-in;
* the **browser**, **OS** and **region** of the initiator, where those are known and displayed;
* a **link or buttons** to confirm, and how long the request is valid.

It does not carry the IP address, the raw `User-Agent`, the user's own profile data, or anything
your application declared about the transaction.

Where it goes depends on the channel: `api.telegram.org` for Telegram, `platform-api.max.ru` for
MAX, `graph.facebook.com` for the WhatsApp Cloud API, and your own SMTP server for Email. In the
other direction, each channel returns the sender's profile — the fields in the inventory table
above. The messenger sees the message content and the recipient because that is what delivering a
message means; the Email channel sees whatever your SMTP relay sees.

**The external event bus.** If you wire the RabbitMQ publisher, transaction events leave the process
carrying the safe attribution only — the masked channel identity among it. The parameters of a
confirmed operation are explicitly excluded from serialization and never reach the bus, whatever the
audit configuration says.

## Logs

Application logs are not an audit trail and are not designed to be evidence. What matters for this
page is what they must never contain, and what the code does about it: identifiers are written as a
**16-character non-reversible fingerprint** (the leading bytes of a SHA-256) rather than as
themselves, so an operator can still stitch the steps of one sign-in together without the log
holding the identity. Tokens, secrets and the initiator's IP are not logged at all.

That is the shipped behaviour of Veriqa's own log statements. Your host, your reverse proxy and your
platform's request logging are outside it — an access log in front of the auth server records client
IPs by default, and bounding *that* is part of the same deployment step that configures
`ForwardedHeaders:KnownProxies`.

## What is left for you to decide

Nothing on this list has a right answer the product can pick for you.

* Whether to keep the initiator context at all, and whether to add a GeoIP database.
* Whether to enable the audit trail, at what retention, and whether records should carry the
  parameters of confirmed operations.
* Whether to persist channel identities — and, if so, how they get deleted.
* Whether to turn on the collected claims store, and with what retention.
* How long expired and revoked token records stay in the OpenIddict database before pruning.
* Where the DataProtection key ring lives.
* Encryption at rest, backups and their retention, at the storage layer.
* How a deletion request from a user is executed across your stores. Veriqa deletes its transaction
  rows on its own schedule and its audit rows on retention; the collected claims store you reach by
  `sub` (endpoint or host call); anything you added is yours to reach.

## Next steps

<Card title="Production hardening" icon="shield-check" href="/docs/guides/hardening">
  The checklist to run before going live.
</Card>

<Card title="Storage" icon="database" href="/docs/guides/storage">
  Which store holds what, and how to wire each one.
</Card>

<Card title="Known limitations" icon="triangle-exclamation" href="/docs/reference/limitations">
  What the product does not do.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.