# For AI agents
Source: https://veriqa.app/docs/ai-agents
How a coding agent reads this documentation: the skill file, llms.txt, Markdown versions of every page and the documentation MCP server.
This documentation is written for people and is equally available to coding agents — Claude Code,
Cursor, Copilot and others. An agent does not have to parse the HTML: every page also exists as
Markdown, and the whole set comes in forms meant to be loaded as context.
## What an agent can read
| Resource | Address | What it is |
| - | - | - |
| Skill file | [`/docs/skill.md`](https://veriqa.app/docs/skill.md) | Read this first: what Veriqa is, how to choose between the two mechanisms, recipes for typical tasks, configuration keys, common mistakes and a checklist before finishing |
| Page index | [`/docs/llms.txt`](https://veriqa.app/docs/llms.txt) | Every page with a one-line description, in navigation order |
| Full text | [`/docs/llms-full.txt`](https://veriqa.app/docs/llms-full.txt) | The whole documentation in one file |
| Any page as Markdown | add `.md` to the page address, e.g. [`/docs/quickstart/dotnet.md`](https://veriqa.app/docs/quickstart/dotnet.md) | The page without the site around it |
| Documentation search | `https://veriqa.app/docs/mcp` | An MCP server that searches this documentation |
The skill file, the index and the full text are in English. Russian pages are not in them, but each
one has its Markdown version too, under `/docs/ru/` — e.g. `/docs/ru/quickstart/dotnet.md`.
## Connect the documentation MCP server
With the MCP server, the agent searches the documentation itself while it works, instead of relying
on what it remembers.
```bash theme={null}
claude mcp add --transport http veriqa-docs https://veriqa.app/docs/mcp
```
Add `--scope project` to share the server with your team through `.mcp.json`.
Open the menu at the top of any page and choose Cursor or VS Code; the editor installs the
server.
Use the menu at the top of any page to copy the MCP server address, then paste it into the
tool's MCP settings.
## Use one page as context
The same menu at the top of every page copies the page as Markdown, opens it as Markdown, or starts
a conversation in Claude or ChatGPT with the page attached. It is the quickest way to hand an agent
exactly the page the task is about.
## What to tell the agent
The agent works better when the task names the mechanism and the deployment mode. A few starting
points:
* *"Read [https://veriqa.app/docs/skill.md](https://veriqa.app/docs/skill.md), then add sign-in through Veriqa embedded in this
ASP.NET Core application. Enable Telegram only."*
* *"Read [https://veriqa.app/docs/skill.md](https://veriqa.app/docs/skill.md). This application signs users in through a self-hosted
Veriqa — wire it up as a plain OpenID Connect client; do not add any Veriqa package."*
* *"Using [https://veriqa.app/docs/reference/confirmation-api.md](https://veriqa.app/docs/reference/confirmation-api.md), ask the account owner to confirm
before `DeleteProject` runs, and delete the project only if the owner has confirmed."*
An application that only signs users in needs no Veriqa package or SDK — it is an ordinary
OpenID Connect relying party. Veriqa packages belong only where the application *is* the issuer
or calls the confirmation API from its server. Agents often get this wrong; say it in the task
if your application is a client.
## See also
* [Samples](/docs/samples) — runnable projects an agent can copy from
* [Configuration reference](/docs/reference/configuration)
* [Known limitations](/docs/reference/limitations)
# Basic scenarios
Source: https://veriqa.app/docs/basic-scenarios
The two mechanisms every Veriqa scenario is built from — sign-in and registration, and the server-to-server transaction — and how they differ.
Every Veriqa scenario is built from one of **two mechanisms**. The user experience is the same in
both — a QR code on screen or a button on the phone, then one tap in a messenger — but who starts
the transaction and what comes back are different.
## Sign-in and registration
The ordinary OpenID Connect flow. Your application sends the user to `/connect/authorize`, the
sign-in window shows a QR code (or a channel button on the phone), the user confirms in the
messenger, and your application exchanges the code for tokens at `/connect/token`.
* **Registration happens in the same step** — there is no form and no password to create.
* The result is an `id_token` with `sub`, `auth_time` and the claims you asked for; the session is
yours to open.
* Your application stays a **plain OIDC client**: no Veriqa package, no SDK, only configuration.
Details: [Passwordless sign-in and registration](/docs/scenarios/passwordless-login),
[How the auth flow works](/docs/concepts/auth-flow).
## Server-to-server transaction
A confirmation your **backend** asks for, with no sign-in and no browser redirect. The backend
gets a token with the Client Credentials grant, creates the transaction with
`POST /api/transaction/confirmation` for a **declared action type**, shows the user the QR code or
link from the response, and reads the outcome with `GET /api/transaction/{id}/result`.
* The user sees **wording declared on your host** with typed slot values — "Delete ticket
A-900?", "Buy for 4.99 EUR?" — never free text from the caller.
* `expected_identities` lets only a specific person confirm — for example the owner of the current
session.
* No session is created. A confirmed transaction can optionally be exchanged for the `id_token` of
the person who confirmed.
Details: [Server-to-server confirmation API](/docs/reference/confirmation-api).
## How they differ
| | Sign-in and registration | Server-to-server transaction |
| - | - | - |
| Who starts it | The user's browser or app, by a redirect | Your backend, by an API call |
| Protocol | Standard OpenID Connect: `/connect/authorize` → `/connect/token` | Client Credentials + `POST /api/transaction/confirmation` |
| What the user confirms | The sign-in to your application | A declared action with its parameters |
| Who may confirm | Anyone — this is how a new account appears | Anyone, or only the people named in `expected_identities` |
| What you get | `id_token` and tokens; you open the session | The outcome — `confirmed`, `declined`, `expired` or `failed`; optionally the confirming person's `id_token` |
| What your application needs | An OIDC client and its configuration | A client entry with `AllowClientCredentials`, a declared action type and a server-side call |
**Rule of thumb.** If the result should be "the user is signed in", use sign-in. If the result
should be "this action is confirmed" — and your server performs the action — use the
server-to-server transaction.
## Scenarios and their mechanism
| Scenario | Mechanism | Details |
| - | - | - |
| Passwordless sign-in and registration | Sign-in | [Passwordless sign-in](/docs/scenarios/passwordless-login) |
| Sign-in with channel linking | Sign-in; server-to-server when there is no redirect | [Channel linking](/docs/scenarios/channel-linking) · [Linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in) |
| Second factor and step-up | Sign-in — a repeat authorize and the `auth_time` check | [Step-up](/docs/scenarios/step-up) |
| Confirming a specific action by the signed-in person | Server-to-server with `expected_identities` | [Confirmation API](/docs/reference/confirmation-api) · videos: [desktop](/docs/videos/desktop-step-up), [phone](/docs/videos/mobile-step-up), [TV](/docs/videos/tv-step-up) |
| Replacing SMS codes | Sign-in | [Replacing SMS codes](/docs/scenarios/sms-replacement) |
| Leads and feedback without CAPTCHA | Sign-in, with no session opened | [Leads without CAPTCHA](/docs/scenarios/lead-capture) · [Confirmed form submission](/docs/integrations/confirmed-form) |
| Account recovery | Sign-in through a linked channel | [Account recovery](/docs/scenarios/account-recovery) |
| Surfaces with no keyboard | Sign-in where there is a browser; server-to-server where there is none | [Sign-in surfaces](/docs/scenarios/surfaces) |
| AI agent approvals | Server-to-server | [Approvals in Claude Code](/docs/research/claude-code-approvals) |
Recipes for all sign-in scenarios are collected in
[Integration scenarios](/docs/guides/scenarios).
# FAQ
Source: https://veriqa.app/docs/faq
Your own messenger bots, on-premise, the cloud, how it differs from passkeys and what it does about bots — short answers with links to the details.
No. The user confirms the sign-in in an app they already have — Telegram, WhatsApp, another
messenger or email. There is no separate authenticator to install.
There are several channels, plus email — the user picks the one they have. The list is on
[Supported channels](/docs/guides/supported-channels).
In your own deployment — yes, and that is the normal mode. Channels are configured with your
credentials: `Channels:Telegram:BotToken`, `Channels:Max:BotToken` and so on. The bot is yours,
the user talks to it under your name, and the tokens live in your secret store.
The full list of keys is in the [configuration reference](/docs/reference/configuration).
In the cloud the set of enabled channels is decided by the project owner in the Console; ask them
whose credentials are in use there.
Yes, that is the primary delivery model: Veriqa runs inside your perimeter, next to your auth
system. The other option is [Veriqa Cloud](/docs/cloud/overview), for when you would rather not run
a server.
The same product, run and maintained by someone other than you: you get a project with its own
issuer, applications and channel set, managed by the project owner in the Console — with no edits
or redeploys of your application.
The cloud suits situations where running a server is not worth it: small sites, off-the-shelf
CMSes, a quick start. Details and current status are in [Veriqa Cloud](/docs/cloud/overview).
Channel messages are not billed per message the way SMS is, so at any noticeable sign-in volume
that is a direct saving. Veriqa's own commercial terms are not published in this documentation — ask
about them separately.
No. Veriqa sits alongside it: your auth system keeps issuing tokens and owning accounts, and
Veriqa adds the trusted channel the user confirms in. To an application Veriqa is a standard
OpenID Connect provider, so your stack does not matter.
No — and this question only concerns .NET. There Veriqa is a connector on top of OpenIddict, which
remains the foundation of authentication. On any other stack OpenIddict is not involved at all:
the application talks to Veriqa over plain OpenID Connect.
Passkeys are phishing-resistant by design — a higher security bar. Veriqa offers the same
convenient flow for simpler scenarios, and works where cross-device passkeys break: Bluetooth off,
a virtual machine, a remote desktop.
In part. Some channels — Telegram, for one — report whether the sender is a bot; Veriqa returns
that flag in the claims, and the restriction policy can block such sign-ins. This is not an
anti-fraud system, but it is a cheap filter that works **before** an account is created.
Yes. It works with WordPress, Drupal, TYPO3, Joomla and Bitrix without changes on the Veriqa side.
The caveats: Joomla's free version only signs people in to an **existing** account, Drupal and
Joomla require an email address on every sign-in (and Telegram provides none), and the Email channel
has been verified with a CMS on Bitrix only. All of it is laid out in
[Veriqa in a CMS](/docs/integrations/overview).
# Veriqa — an open-source project for passwordless authentication through popular messengers and email: Telegram, WhatsApp, Instagram, Messenger, WeChat, Viber, Email and more
Source: https://veriqa.app/docs/index
Passwordless authentication through Telegram, WhatsApp, Instagram, Messenger, WeChat, Viber, other channels or Email: a QR code or a single button: your users need no sign-up, no password and no SMS code. Standard OpenID Connect for any stack.
## The idea is simple
**For your users** — easy authentication: scanning a QR code or tapping a single button is all it
takes to sign in to your app or website.
**No** sign-up, **no** extra software, **no** password and **no** SMS code.
**For your website or app** — setup in 15 minutes on any stack, and data about the
user signing in, straight from the messenger: name, language, photo and, with the user's consent,
[phone number](/docs/guides/phone-number).
One more benefit: at sign-in your website automatically gets a live channel to the user through
their messenger, and you stop paying for SMS.
## What it looks like
On a computer, a TV or a kiosk, the user scans the QR code on the sign-in page; on a phone they
tap a button. The messenger they already use opens — Telegram, WhatsApp or
[another popular one](/docs/guides/supported-channels) — and a single tap there confirms the sign-in.
Even a user's first sign-in takes only 4–6 seconds.
[See the measurements](/docs/concepts/sign-in-comparison).
The same flow can also confirm AI agent actions and sensitive operations: deleting important
data, changing payment details, granting access to another person.
[Try the live examples](https://veriqa.app/demo?lang=en).
[More videos](/docs/videos/tv-sign-in) · [Try the live examples](https://veriqa.app/demo?lang=en)
Veriqa is tested with popular CMSs: [WordPress](/docs/integrations/wordpress),
[Drupal](/docs/integrations/drupal), [Joomla](/docs/integrations/joomla), [TYPO3](/docs/integrations/typo3) and
[Bitrix](/docs/integrations/bitrix). See [Veriqa in a CMS](/docs/integrations/overview) for details.
It works with [.NET](/docs/quickstart/dotnet), [Node](/docs/quickstart/node), [Python](/docs/quickstart/python),
[Java](/docs/quickstart/java), Go, PHP and any other stack.
Veriqa ships as [NuGet packages](/docs/quickstart/dotnet), a [Docker image](/docs/quickstart/self-hosted) or a
[self-contained binary](/docs/quickstart/os-service) for Linux and Windows.
You can [write a channel in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language)
for your corporate messenger or bot and plug it in without changing the core.
The code is open source under MPL-2.0 and free for companies of any size.
## What you get
Add a messenger sign-in button to your website, next to the sign-in methods you already have. You
get less friction at sign-in, lower costs, stronger security and a live connection with the user.
Setting up Veriqa takes 15–30 minutes and costs nothing, and your users get a quick and convenient
way to authenticate on your website or in your app.
### For your users
No password, no SMS code, no sign-up form. Scanning a QR code or tapping a button, then
confirming in the messenger, is all it takes.
The messenger is already on their phone. No new app, no authenticator app and no password to forget
and recover.
On a computer, a TV or a kiosk it is a QR code; on a phone, a button. The confirmation is always
the same tap in the messenger.
### For your product
Instead of a password, an SMS code and switching between screens — **one familiar action**: opening the camera
on the phone. Sign-in or sign-up takes **4–6 seconds**, and that is the whole path:
pointing the camera, tapping the link and confirming in the messenger.
SMS is billed per message and gets more expensive year over year. Sign-in and confirmation via
Veriqa **aren't billed per message**, and Veriqa itself is **open source**.
Along with the sign-in, your product gets a **connected messenger channel** — for notifications,
confirmations and future service use cases.
Nothing for the user to install and **no new client-side code** — Veriqa plugs into your existing app
through configuration.
“Forgot password” is no longer an ordeal: a **linked trusted channel** becomes a reliable recovery
path, without a chain of emails and codes.
Veriqa stores the bare minimum: **an encrypted identifier** that each messenger assigns to the
user. No names, no phone numbers. The data a sign-in needs appears only during the
sign-in and can be kept in memory only.
## Get started
Start with the [quickstart](/docs/quickstart/self-hosted): the Veriqa server in a container and sign-in
for your app on any stack.
## Use cases
Examples of how Veriqa handles everyday tasks for websites and apps.
Your website or app shows a QR code, and the user confirms the sign-in in their messenger — **no
password needed**. A new user signs up with the same action, **with no form and no CAPTCHA**: the
messenger confirmation itself acts as the bot barrier.
[Learn more](/docs/scenarios/passwordless-login) · [Video](/docs/videos/desktop-sign-in)
Before an AI agent calls a tool or receives access, it asks a human for approval. The request
arrives **in the user's own messenger** — a surface the agent does not control — and the answer
goes into the audit trail.
[Learn more](/docs/research/agent-approvals) · [Video](/docs/videos/claude-code-tool-call)
The user signs in through a messenger, and with the same action your product gets a **live
channel** to them — Telegram, WhatsApp, another messenger or email, depending on where they
confirmed. One action, **two results**: you can use the channel for notifications and service
scenarios.
[Learn more](/docs/scenarios/channel-linking)
Instead of typing a code from an SMS, the user confirms the sign-in with a button in their
messenger. You pay **no per-message charge** and get **full authentication** rather than just a
delivered code.
[Learn more](/docs/scenarios/sms-replacement) · [Video](/docs/videos/mobile-sign-in)
Before sending a form, a visitor verifies themselves through a messenger — it takes a few seconds.
Your site gets a **verified contact** and a live channel instead of a "dead" address typed into a
field. **Tested and working** on WordPress, Bitrix, Drupal, Joomla, TYPO3 and other CMSs.
[Learn more](/docs/scenarios/lead-capture)
When a user does something **sensitive** — deletes important data, changes payment details, grants
someone else access — your product asks them to confirm it. They confirm with **a single tap in
their messenger**, with no code to type.
[Learn more](/docs/scenarios/step-up) · [Video](/docs/videos/desktop-step-up)
There is more: [sign-in on surfaces with no keyboard](/docs/scenarios/surfaces) and
[account recovery](/docs/scenarios/account-recovery); the overview is in
[Integration scenarios](/docs/guides/scenarios). [Basic scenarios](/docs/basic-scenarios) shows which of the two base
mechanisms — sign-in or the server-to-server transaction — each one uses.
## Sign-in methods compared
| Sign-in method | Setup: hands-on time | First sign-in | Repeat sign-in |
| - | - | - | - |
| Password | hours | no data | \~30 s, \~69 s with a second factor |
| SMS code | hours of work and days of waiting | no data | \~17 s |
| Social sign-in | hours for each provider, plus waiting for app review | 30 s | 3.4 s |
| Passkey | minutes, hours with the fallback sign-in | 24 s with the key on the phone | 3–15 s, 18 s with the key on the phone |
| **Veriqa** | **15–30 minutes** | **4–6 s** | **4–6 s** |
The Veriqa times are for a sign-in with confirmation; with automatic sign-in it takes 2.4–3.5 s.
The source of every figure, what the user does step by step and how the setup was assessed are in
the [full comparison](/docs/concepts/sign-in-comparison). How Veriqa fundamentally differs from passkeys,
messenger sign-in widgets, SMS codes and the device flow is covered
[separately](/docs/concepts/comparison).
## Architecture
Veriqa is a bridge between your application and the user's trusted messenger. It opens a
sign-in or confirmation transaction, binds it to the chosen channel, waits for the user's
confirmation and hands the result back — as tokens for the sign-in, or as an outcome to your
backend.
```mermaid theme={null}
flowchart LR
App["Your application (web / desktop / TV)"]
Backend["Your backend"]
subgraph Veriqa["Veriqa"]
direction TB
OI["OIDC endpoints (OpenIddict)"]
TE["Transaction engine"]
CA["Channel adapters"]
end
Channel["Trusted app Telegram / WhatsApp / other channels / Email"]
App <-->|OpenID Connect| OI
Backend <-->|Confirmation API| TE
OI --- TE
TE --- CA
CA <--> Channel
```
What Veriqa is made of, block by block, and its main technical characteristics:
1. **Transaction orchestration.** A sign-in is a transaction with an explicit state, a lifetime and
a single outcome. It survives a change of device, expires on its own, is idempotent to repeats and
lands in the audit trail — instead of a request left hanging somewhere between the browser and the
messenger.
2. **Channel adapters.** Telegram, WhatsApp, Email and others are switched on by configuration. A
channel of your own needs no change to the core, and it does not have to be .NET: an external
service in any language over HTTP, or the bot you already run, as is. Your channels take their
place in the chain alongside the built-in ones.
3. **Integration.** Any stack, standard OIDC. On .NET — a connector on top of OpenIddict.
Everywhere else — a standard OIDC provider: Node, Java, Python, Go, PHP. In an IAM such as Logto
it is added as an ordinary social connector in a couple of minutes.
4. **Settings and multi-project support.** Tokens, channels, the texts and branding of the sign-in
page, policies, limits, the audit trail — all of it is configuration, with four levels of
ownership, including the core, the tenant and the application, and a defined resolution order. One
installation serves many projects and bots, each with its own settings.
5. **Audit trail and observability.** The audit trail follows your rules and can be switched off
entirely. Identities are always masked, and tokens are written neither to the log nor to the
audit trail — that is how it is built, not a setting you have to remember. Metrics, logs and
traces work the same way.
6. **Stack.** .NET 10, shipped as NuGet packages that plug into a running application. Clean
architecture; storage in memory, EF Core (PostgreSQL, SQL Server and others) or Redis. The sign-in
page is plain JS — no frameworks, no external dependencies. The data does not leave your
perimeter.
Details are in [Architecture](/docs/concepts/architecture).
## Deployment
| Mode | What it is | Start here |
| - | - | - |
| **Embedded (NuGet)** | Inside your ASP.NET Core host as an OpenIddict connector — no separate service to operate | [Quickstart .NET](/docs/quickstart/dotnet) |
| **Standalone (Docker)** | Its own OIDC server in a container; your application, on any stack, doesn't reference any Veriqa package | [Quickstart self-hosted](/docs/quickstart/self-hosted) |
| **OS service (archive)** | The same standalone server without a container — a systemd unit or a Windows service; no Docker and no .NET runtime needed | [Install as an OS service](/docs/quickstart/os-service) |
Veriqa Cloud is in development; its API contract is described in [Cloud](/docs/cloud/overview).
**A note on .NET.** In embedded mode Veriqa **extends** the OpenIddict setup in your
ASP.NET Core application rather than replacing it: it runs the sign-in transaction between
`/connect/authorize` and `/connect/token` and leaves token issuance to OpenIddict. On any other
stack OpenIddict is not involved at all — the application talks to Veriqa over plain OpenID
Connect.
## Channels
Eight channels are built into the core, including **Telegram, WhatsApp** (direct or through
Twilio), **Instagram Direct, Messenger, WeChat, Viber and Email**. Any other channel — a corporate
messenger, a bot of your own, a regional platform — connects as an
[HTTP channel](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) written in any
language. Pseudo-channels for sign-in with Google, Apple and Huawei accounts ship as a
sample in the repository. Adapters for LINE, Slack, Microsoft Teams, Discord, Zalo, KakaoTalk,
Threema and more are already implemented — they live in the `channel-adapters` repository, together
with sample hosts (see the [source code](https://veriqa.app/source)).
The full list, with the status of each channel, is on the
[website's channels page](https://veriqa.app/channels) and in
[Supported channels](/docs/guides/supported-channels).
# Quickstart .NET
Source: https://veriqa.app/docs/quickstart/dotnet
Add Veriqa to an ASP.NET Core host as an embedded OpenIddict connector.
Veriqa embeds in your ASP.NET Core host and runs as an OpenIddict auth server: it hosts the OIDC
endpoints, the sign-in page with QR and channels, the transaction engine and the messenger
adapters. No separate process or Docker required.
Prefer to keep Veriqa out of your build entirely? It also runs as a **separate server** your app
talks to over plain OIDC — in the cloud, in Docker or as an OS service — with no Veriqa package
in your dependency tree or SBOM. [Veriqa running elsewhere](#veriqa-running-elsewhere) covers what
that changes for a .NET client.
Samples use placeholders (`my-app`, bot tokens). Replace them with your own values and never
commit real secrets: keep them in user-secrets in development and a secret store in production.
A runnable sample lives at `samples/dotnet/inproc/login/` in the repository — the
repository address is at [veriqa.app/source](https://veriqa.app/source). Clone it and start from there instead of
wiring everything by hand.
## 1. Install the packages
```bash .NET CLI theme={null}
dotnet add package Veriqa.Core.AuthServer
dotnet add package Veriqa.Core.TransactionEngine
# Channels — one metapackage with the base set (Telegram, WhatsApp, Email):
dotnet add package Veriqa.Core.BaseChannels
# MAX is not part of the metapackage — install it separately when you need it:
dotnet add package Veriqa.Core.ChannelAdapter.Max
# Only for the EF Core stores on PostgreSQL — the migrations assemblies (step 2):
dotnet add package Veriqa.Core.AuthServer.Migrations.PostgreSql
dotnet add package Veriqa.Core.TransactionEngine.Migrations.PostgreSql
```
```xml PackageReference theme={null}
```
**Need a single channel? Install just that one.** Every channel ships on its own and brings only
its own SDK: `Veriqa.Core.ChannelAdapter.Telegram`, `.WhatsApp`, `.Max`, `.Email`. A deployment
that signs users in through Telegram then never receives an email library. The
`Veriqa.Core.ChannelAdapter` package arrives with any of these packages — you do not install it
separately. Installing both the metapackage and a separate channel is fine: NuGet resolves it to
one copy.
## 2. Register the services
`AddVeriqaAuthServer` reads configuration from the host's standard `IConfiguration`. Inside it you
choose the OpenIddict store, configure the transaction store and add the channels you need.
```csharp Program.cs theme={null}
using Microsoft.EntityFrameworkCore; // UseNpgsql — production lines only (Npgsql arrives with the *.Migrations.PostgreSql packages)
using Veriqa.Core.AuthServer.DependencyInjection;
using Veriqa.Core.AuthServer.Infrastructure; // UseSecurityHeaders (step 3)
using Veriqa.Core.ChannelAdapter.DependencyInjection; // AddTelegram, AddWhatsApp, AddMax, AddEmail
using Veriqa.Core.TransactionEngine.DependencyInjection; // UseEfCoreStore — production lines only
var builder = WebApplication.CreateBuilder(args);
// The production lines below take the connection string from the host's configuration:
// var connectionString = builder.Configuration.GetConnectionString("Veriqa");
builder.Services.AddVeriqaAuthServer(builder.Configuration, builder.Environment, authServer =>
{
// OpenIddict store (clients, authorizations, tokens). There is no default — choose it
// explicitly. For development — the volatile in-memory one:
authServer.UseInMemoryOpenIddictStore();
// For production — a relational provider instead of the line above (provider and connection
// string are owned by the host; the migrations ship in the
// Veriqa.Core.AuthServer.Migrations.PostgreSql package):
// authServer.UseOpenIddictDatabase(ef => ef.UseNpgsql(connectionString,
// npgsql => npgsql.MigrationsAssembly("Veriqa.Core.AuthServer.Migrations.PostgreSql")));
// Transaction store
authServer.ConfigureTransactionEngine(te =>
{
// For development — InMemory:
te.UseInMemoryStore();
// For production — EF Core (the method itself ships in the
// Veriqa.Core.TransactionEngine.EntityFrameworkCore package; provider and connection
// string are owned by the host; the migrations ship in the
// Veriqa.Core.TransactionEngine.Migrations.PostgreSql package):
// te.UseEfCoreStore(ef => ef.UseNpgsql(connectionString,
// npgsql => npgsql.MigrationsAssembly("Veriqa.Core.TransactionEngine.Migrations.PostgreSql")));
});
// Confirmation channels
authServer.AddChannelAdapters(adapters =>
{
adapters.AddTelegram();
adapters.AddWhatsApp();
adapters.AddMax();
adapters.AddEmail();
});
});
```
**The OpenIddict store has no default.** A host that calls neither
`UseInMemoryOpenIddictStore()` nor `UseOpenIddictDatabase(...)` **fails to start** outside the
`Development` environment: the startup check stops the application and names the fix. In
`Development` the same text is logged as a `Warning` and the host starts. An explicit `InMemory`
starts in any environment but warns on every startup that the store is volatile: clients, tokens
and authorizations do not survive a restart and are not shared between replicas.
**Outside `Development` the token certificates are required too.** These are the OpenIddict keys the
server signs and encrypts `id_token` / `access_token` with — not the HTTPS certificate. Without
`Veriqa:OpenIddict:Server:SigningCertificatePath` (or `…SigningCertificateBase64`) and the matching
`EncryptionCertificate…` key the host fails to start; in `Development` development keys are generated
instead. The keys are in the [configuration reference](/docs/reference/configuration#server).
Add only the channels you actually use. Each adapter activates only when its configuration section
is enabled (`"Enabled": true`) — otherwise only its options and validator are registered.
## 3. Build the middleware pipeline
```csharp Program.cs theme={null}
var app = builder.Build();
app.UseSecurityHeaders(); // CSP, X-Frame-Options
app.UseStaticFiles(); // SignalR client for live status updates
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
// Veriqa endpoints: OIDC + channel webhooks + transaction status + SignalR hub
app.MapVeriqaAuthServer();
app.Run();
```
`MapVeriqaAuthServer` maps the **Email channel** endpoints (magic link and the one-tap email)
automatically — but only when the channel is enabled in configuration
(`Veriqa:Channels:Email:Enabled: true`). No separate call is needed.
The `wwwroot/locales` directory is optional: without it channel messages go out in the base language,
and the startup warning `Locale files directory not found …` is expected. You do not have to translate
anything by hand: ready-made locales (`en`, `ru`, `zh`, `pseudo`) ship in the
`Veriqa.Core.ChannelLocales` package — [Localization](/docs/guides/customization#localization)
explains how to put them to work. Also expected with the default configuration is
`Initiator context geolocation is enabled but the GeoIP provider is unavailable`: sign-in works, the
geo fields of the initiator context simply stay empty. In `Development` the startup is also followed
by a few dozen diagnostic `info` lines about the resolved configuration — that is normal.
## 4. Configure
Channel secrets and OIDC clients are set in configuration. Everything Veriqa owns lives under
the root `Veriqa` key: channel sections under `Veriqa:Channels`, auth-server settings alongside
them in the same root.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Telegram": {
"Enabled": true,
"InboundVerification": "shared_secret",
"BotToken": "YOUR_BOT_TOKEN",
"UpdateMode": "Webhook",
"WebhookBaseUrl": "https://your-domain.com",
"WebhookSecretToken": "YOUR_SECRET"
},
"WhatsApp": {
"Enabled": false,
"InboundVerification": "signature",
"Provider": "MetaCloudApi",
"BusinessPhoneNumber": "+15551234567",
"MetaCloudApi": {
"PhoneNumberId": "YOUR_PHONE_NUMBER_ID",
"AccessToken": "YOUR_ACCESS_TOKEN",
"AppSecret": "YOUR_APP_SECRET",
"WebhookVerifyToken": "YOUR_VERIFY_TOKEN"
}
},
"Max": {
"Enabled": false,
"InboundVerification": "shared_secret",
"BotToken": "YOUR_BOT_TOKEN",
"UpdateMode": "Webhook",
"WebhookBaseUrl": "https://your-domain.com",
"WebhookSecretToken": "YOUR_SECRET"
},
"OutcomeNotice": {
"DisplayIntent": "ReplacePrompt"
}
},
"MessageTemplates": {
"outcome-receipt-confirmed": {
"Contract": {
"Slots": [ { "Name": "app", "Type": "string", "Source": "ServerInitiatorContext" } ]
},
"ByType": {
"login": {
"BySurface": {
"in-channel-messenger": {
"Templates": [ "Signed in to {app} ✅", "Signed in ✅" ]
}
}
}
}
},
"outcome-receipt-declined": {
"Contract": {
"Slots": [ { "Name": "app", "Type": "string", "Source": "ServerInitiatorContext" } ]
},
"ByType": {
"login": {
"BySurface": {
"in-channel-messenger": {
"Templates": [ "Sign-in to {app} declined ❌", "Sign-in declined ❌" ]
}
}
}
}
}
},
"TransactionEngine": {
"TransactionTtlSeconds": 300
},
"OpenIddict": {
"Clients": [
{
"ClientId": "my-app",
"DisplayName": "My Application",
"AllowedRedirectUris": [ "https://localhost:7020/signin-oidc" ],
"AllowedScopes": [ "openid", "profile", "email", "offline_access" ],
"AllowRefreshTokens": true
}
]
}
}
}
```
**Replace the placeholders before the first run.** `YOUR_BOT_TOKEN` and the other placeholders are
not values the host can start with. Telegram and MAX in `UpdateMode: Webhook` — the mode shown above — register their webhook
at boot, so a placeholder token keeps the host from starting. WhatsApp is the quieter case: once
`"Enabled": true`, its credentials are checked at startup for **presence** only — a missing or
empty one does keep the host from starting, a placeholder string passes — so the host comes up
clean and the unusable value surfaces only when the first confirmation is sent. Get the real
credentials in
[Channel setup](/docs/guides/channels) — that is also why exactly one channel is `"Enabled": true`
above. Switch the others on when you have their credentials.
**The last two sections are what the user reads when the sign-in ends.**
`Channels:OutcomeNotice:DisplayIntent` says where the outcome receipt appears:
`ReplacePrompt` — the shipped value, stated here so the setting is visible — puts the receipt in
place of the message that asked the question, and `NewMessage` leaves the question standing
and adds a message of its own. It is an intent rather than a promise: Telegram and MAX honor it,
WhatsApp always sends a new message, and Email shows no receipt at all.
`MessageTemplates` words those receipts. The product ships its own wordings at
`ByType:login:BySurface:…`, and a level exhausts every step of the address before the resolution
drops to the level below — so a redeclaration is reached only at the **same** address, which is
why `Templates` above sits that deep. `{app}` is a server slot: for a sign-in it is the
`DisplayName` of the client. The last step of each ladder uses no slot, because no slot of a
receipt is guaranteed. The full rules are in
[Configuration reference](/docs/reference/configuration#veriqamessagetemplates).
WhatsApp uses the official **Meta Cloud API** (`Provider: MetaCloudApi`); `BusinessPhoneNumber`
is in E.164 format for the deep link. Wire only the channels you use — a section without
`"Enabled": true` does not activate the channel, which is why the two sections above are shown
switched off rather than removed. Full Email setup (SMTP for Pull and inbound for Push) is in
[Channel setup](/docs/guides/channels#email).
`InboundVerification` is a **required** key of every channel section: it declares how the
authenticity of an incoming event of that channel is verified. An enabled channel without it
keeps the host from starting. The values above are for the modes shown (`UpdateMode: Webhook`);
in `Polling` mode the value is `outbound_fetch`. The table for every channel and mode is in
[Channel setup](/docs/guides/channels).
**Running on `localhost`? Switch the channel to `Polling`.** The Telegram section above is shown
in `Webhook` mode, which needs a public HTTPS address: the host registers the webhook at boot,
and Telegram has to be able to reach it. It cannot reach `https://localhost:7300` — the address
the [checkpoint](#10-checkpoint) at the end of this page runs on. `Polling` needs no public URL,
because Veriqa fetches the updates itself:
```json theme={null}
"Telegram": {
"Enabled": true,
"InboundVerification": "outbound_fetch",
"BotToken": "YOUR_BOT_TOKEN",
"UpdateMode": "Polling"
}
```
The two keys change together: `outbound_fetch` is the value that goes with `Polling`, and
`shared_secret` there produces a startup `Warning`. `WebhookBaseUrl` and `WebhookSecretToken`
are unused in this mode and can be left out; any other bot channel switches over on the same two
keys. Good for reaching a working sign-in before a domain and a proxy exist, not for a deployed
instance — and even then, one poller per bot
([running more than one instance](/docs/quickstart/self-hosted#running-more-than-one-instance)).
`redirect_uri` is matched against `AllowedRedirectUris` exactly — wildcards are not supported.
The startup validator requires absolute URIs; use `https` in production (`http` only for a
loopback address such as `localhost` or `127.0.0.1`, in development).
## 5. Wire up the client application
Everything above is the issuer side. The client that signs in against it is an **ordinary OIDC
relying party**: nothing Veriqa-specific goes into it — this is the same wiring you would write
for any OpenID Connect provider. In ASP.NET Core that wiring comes from one package:
```bash theme={null}
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
```
```csharp Program.cs (the client application) theme={null}
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
// The address of the host running Veriqa. Endpoints and keys are picked up through
// discovery ({Authority}/.well-known/openid-configuration) — none are spelled out here.
options.Authority = "https://localhost:7300";
options.ClientId = "my-app";
options.ResponseType = "code";
options.UsePkce = true;
// Must equal an AllowedRedirectUris entry from step 4 — the comparison is exact.
options.CallbackPath = "/signin-oidc";
// A user who presses "Cancel sign-in" on the Veriqa page
// comes back with error=access_denied. That is an answer, not a fault: with the path set
// the handler redirects to the route mapped below. With no path set the refusal
// would go to OnRemoteFailure below, like any other failed return.
options.AccessDeniedPath = "/signin-cancelled";
// Every other failed return — Veriqa answering error=server_error, say, because the channel
// did not deliver the confirmation — raises OnRemoteFailure; with no handler for it the
// return ends in an HTTP 500. The user is taken to a page that offers to start over. The
// error text is deliberately not shown to the user: it is written to the log instead.
options.Events.OnRemoteFailure = context =>
{
context.HttpContext.RequestServices.GetRequiredService>()
.LogWarning(context.Failure, "The sign-in return carried an error.");
context.HandleResponse();
context.Response.Redirect("/signin-failed");
return Task.CompletedTask;
};
// The handler requests openid and profile itself — only extra scopes go here.
// Each one must be listed in the client's AllowedScopes on the issuer.
options.Scope.Add("email");
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
// Veriqa issues the user's name as the short OIDC `name` claim, while User.Identity.Name
// looks up ClaimTypes.Name (the long WS-Federation URI) by default — without this line the
// page below greets an empty name after a successful sign-in.
options.TokenValidationParameters.NameClaimType = "name";
});
builder.Services.AddAuthorization();
```
An ordinary `Challenge` sends an unauthenticated visitor to sign-in — onto the Veriqa page with
the QR code and channel buttons:
```csharp Program.cs (the client application) theme={null}
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (ClaimsPrincipal user) =>
user.Identity?.IsAuthenticated is true
? Results.Text($"Hello, {user.Identity.Name}!")
: Results.Challenge());
// Where the handler sends a user who did not go through with the sign-in (AccessDeniedPath above).
// It is a route of its own because "/" challenges an anonymous visitor: sent there, the user would
// be put straight back on the sign-in page they have just left.
app.MapGet("/signin-cancelled", () => Results.Text("Sign-in was canceled. Open / to sign in again."));
// Where OnRemoteFailure above sends a user whose sign-in ended in an error. Like the route before
// it, it is anonymous and starts nothing by itself: redirected to "/", a failure that repeats
// would loop.
app.MapGet("/signin-failed", () => Results.Text("Sign-in could not be completed. Open / to sign in again."));
```
The second route is where a sign-in that did not happen ends. The sign-in page has a
[**Cancel sign-in** button](/docs/guides/customization#cancel-button), on by default; a user who presses
it comes back to `/signin-oidc` with `error=access_denied`.
`AccessDeniedPath` is what turns that answer into a redirect to this route — the handler has no
default for it, and without the line the refusal goes to `OnRemoteFailure`, that is, to the third
route.
The third route takes every other failed return. A sign-in that breaks on the Veriqa side comes
back with `error=server_error` — see [Troubleshooting](/docs/guides/troubleshooting). `AccessDeniedPath`
covers `access_denied` only: for the rest the handler raises `OnRemoteFailure`, and with no handler
for that event the return ends in an HTTP 500. The page offers to start again and does not show the
error text; the handler writes it to the application log, which names the error code and explains
failures on the client side, while the reason for a `server_error` is in the Veriqa log and in the
audit trail.
Three values have to agree with the configuration from step 4. Each mismatch has its own symptom,
which is how you tell them apart:
| Client | Issuer (`Veriqa:OpenIddict:Clients[]`) | If it does not match |
| - | - | - |
| `Authority` | the address of the host running `MapVeriqaAuthServer` | the client fails at discovery on `{Authority}/.well-known/openid-configuration` — there is no OIDC error response at all |
| `ClientId` | `ClientId` | `invalid_client` from OpenIddict |
| `CallbackPath` (on the client's address) | an `AllowedRedirectUris` entry | `invalid_request`: the comparison is literal, wildcards are not supported |
`dotnet new web` gives every project random ports, so out of the box neither `Authority` nor the
`redirect_uri` points at a running process — [step 10](#10-checkpoint) starts both on the addresses
used here.
The `my-app` client from step 4 is declared **without** a `ClientSecret`, which makes it a public
client — and Veriqa requires PKCE (S256) from it. Hence `UsePkce = true` and no `ClientSecret`.
For a confidential client the secret is set on **both** sides.
A runnable "issuer + client" pair lives in the repository
([veriqa.app/source](https://veriqa.app/source)): `samples/dotnet/inproc/login` (the
host with Veriqa, port 7300) and `samples/dotnet/inproc/login-client` (this application, port
7020\). Their configuration matches out of the box — start both and walk through the sign-in. For
it to **reach the end**, the issuer needs one enabled channel with credentials: the sample ships with
every channel `"Enabled": false`, and with none enabled the sign-in page answers
`no_channels_available`.
Veriqa exposes no end-session endpoint, so federated sign-out is not supported: signing out on the
client clears its own cookie session but not the issuer's.
## 6. How the sign-in flow works
The user starts the transaction on the desktop and confirms it in a trusted channel on their
phone. Veriqa tracks the transaction and only then lets OpenIddict issue the result.
```mermaid theme={null}
sequenceDiagram
autonumber
participant App as Client app (desktop)
participant V as Veriqa (in your host)
participant OI as OpenIddict
participant Ch as Trusted channel (phone)
App->>V: Start auth transaction (/connect/authorize)
V-->>App: Sign-in page + QR / channel buttons
Ch->>V: User opens channel, confirms sign-in
V->>V: Update transaction state
App->>V: Live status (SignalR) or polling
opt Confirmation on the web page (by default for Email)
V-->>App: Confirmation page (/connect/authorize/callback)
App->>V: User confirms (/connect/authorize/callback/confirm)
end
V->>OI: Complete authentication
OI-->>App: code → /connect/token → tokens
```
## 7. Endpoints
`MapVeriqaAuthServer` maps the standard OIDC contract and the service endpoints:
| Endpoint | Method | Purpose |
| - | - | - |
| `/connect/authorize` | GET | Enter the Authorization Code Flow |
| `/connect/authorize/callback` | GET | Callback after channel confirmation |
| `/connect/authorize/callback/confirm` | POST | On-page sign-in confirmation button. `Veriqa:LoginConfirmation:Mode` defaults to `ChannelDefault`: a channel that can confirm inside itself (Telegram, WhatsApp, MAX) asks there, Email asks on this page; `OnWebPage` asks on this page for every channel |
| `/connect/authorize/callback/claims` (+ `/skip`, `/cancel`, `/email`) | POST | Actions of the page that collects profile fields the sign-in is still missing: submit the fields, skip the optional ones, cancel the sign-in; `/email` — the actions of its email block (send a code or a link, check the code, save the address, switch the way, skip the email) |
| `/auth/claims?session_id=…&form_token=…` | GET | The same page on the device where the sign-in was confirmed, opened by a link that carries a form token valid for one sign-in; its form posts to the actions above with that token instead of the browser cookie |
| `/connect/token` | POST | Exchange `code` for tokens |
| `/connect/userinfo` | GET | User claims |
| `/connect/revoke` | POST | Token revocation (RFC 7009) — handled natively by OpenIddict |
| `/api/transaction/{id}/status` | GET | Transaction status polling (non-WebSocket fallback) |
| `/api/transaction/confirmation` | POST | Server-to-server creation of a confirmation transaction: the application authenticates with client credentials and receives a `transaction_id` and a way in for the user |
| `/api/transaction/{id}/result` | GET | Result of a confirmation transaction (application token required) |
| `/api/link-handle/redeem` | POST | Server-to-server exchange of a [link handle](/docs/scenarios/channel-linking#two-bots-handing-the-user-over-to-your-bot) from a post-completion button for the identity of the signed-in user (application token required) |
| `/api/collected-claims?sub=…` | DELETE | Deletion of the collected claims of a subject (client credentials of a client with `AllowCollectedClaimsDeletion`); mapped only when a [collected claims store](/docs/guides/storage#collected-claims-store) is registered |
| `/auth/transaction?session_id=…` | GET | Entry page of an already created confirmation transaction |
| `/auth/transaction/confirm` (+ `/accept`, `/decline`) | GET / POST | Confirmation question page and the user's answer |
| `/hubs/auth` | WebSocket (SignalR) | Live transaction status for the sign-in page |
| `/api/channels/telegram/webhook` | POST | Telegram adapter webhook |
| `/api/channels/whatsapp/webhook` | GET/POST | WhatsApp adapter verification and webhook |
| `/api/channels/max/webhook` | POST | MAX adapter webhook |
The Email channel endpoints are mapped by the same `MapVeriqaAuthServer` call automatically —
only when the channel is enabled (`Veriqa:Channels:Email:Enabled: true`):
| Endpoint | Method | Purpose |
| - | - | - |
| `/auth/email/start` | GET/POST | Email input page and magic link delivery (Pull) |
| `/auth/email/confirm` | GET/POST | Magic link confirmation (Pull) |
| `/auth/email/claim` | GET/POST | Email address confirmation by the link of a claims-completion email |
| `/api/channels/email/inbound` | POST | Inbound email webhook from the inbound provider (Push) |
## 8. User claims
The default mapper builds the final claim set from the completed transaction:
| Claim | Meaning |
| - | - |
| `sub` | Stable subject identifier (required) |
| `amr` | Authentication method — the confirmation channel type (`telegram`, `whatsapp`, `max`, `email`). In the `id_token` it is an **array of strings** even for a single method: `"amr": ["telegram"]` (OIDC Core 1.0 §2). The same method is also visible in the `access_token` and the `/connect/userinfo` response |
| `auth_time` | When authentication completed (Unix) |
| `name`, `email`, … | Extra claims from the resolved identity — depend on the channel and requested scopes |
| `channel_type`, `channel_user_id` | The confirmation channel and the user's id inside it — **only with the Veriqa `channel` scope** |
| `picture` | The user's avatar — **only with the Veriqa `avatar` scope** |
`picture` arrives only when all three hold: the client requests the `avatar` scope
(`options.Scope.Add("avatar")`), the scope is listed in the client's `AllowedScopes`, and
`GetClaimsFromUserInfoEndpoint = true` — the avatar is never put into the `id_token`, only userinfo
returns it. To read it without inflating the cookie, see
[OIDC + Veriqa explainer](/docs/concepts/oidc-explainer#resolved-identity-and-claims).
Override the mapping with your own `IClaimsMapper` — see [Configuration & customization](/docs/guides/customization#claims-mapper).
## 9. Channel selection via acr\_values
A client can restrict sign-in to a specific channel with the standard OIDC `acr_values` parameter
in `channel:{type}` form:
```
GET /connect/authorize
?client_id=my-app
&response_type=code
&scope=openid profile
&acr_values=channel:telegram
```
Multiple values (`channel:telegram channel:whatsapp`) let the user pick from the listed ones.
Without the parameter, all enabled channels are available.
## 10. Checkpoint
Start both processes on the addresses the configuration expects — the issuer on the `Authority`
port, the client on the port of its `redirect_uri`:
```bash theme={null}
# In the issuer project (the host with Veriqa):
dotnet run --urls "https://localhost:7300"
# In the client project, in a second terminal:
dotnet run --urls "https://localhost:7020"
```
Putting the same addresses into `applicationUrl` of each project's `Properties/launchSettings.json`
works too. The client calls the issuer over `https` itself, so the ASP.NET Core development
certificate has to exist and be trusted: create it with `dotnet dev-certs https`, then trust it with
`dotnet dev-certs https --trust`. On Linux the trust command works as well and prints the
`SSL_CERT_DIR` value to set; the `There was an error trusting the HTTPS developer certificate.` line
it ends with is **expected** there and does not mean the step failed — export the value it printed,
keeping your distribution's own certificate directory in the list so that other chains (a `restore`
from nuget.org, any outbound HTTPS call) still verify:
```bash theme={null}
export SSL_CERT_DIR="$HOME/.aspnet/dev-certs/trust:$(openssl version -d | cut -d'"' -f2)/certs"
```
The second entry is the certificate directory of your distribution, and the command works out where
it is instead of hard-coding it: `openssl version -d` prints `OPENSSLDIR` — the OpenSSL
configuration directory, not the certificates themselves — and the CA files with the hash links
OpenSSL looks up sit in its `certs` subdirectory, hence the `/certs` on the end. On Debian and
Ubuntu that resolves to `/usr/lib/ssl/certs`; on other distributions the prefix differs, which is
why it is read rather than written out. After that the certificate is trusted. Background is in the
"Trust HTTPS certificate on Linux" section of
[Enforce HTTPS in ASP.NET Core](https://learn.microsoft.com/aspnet/core/security/enforcing-ssl).
Start both processes as shown above and open the client at `https://localhost:7020`.
Choose "Sign in with Veriqa" and scan the QR code with a trusted channel on your phone. Email
shows a QR code in Push mode; in Pull mode it shows an address field and a "Get a link" button instead.
Confirm the request in Telegram / WhatsApp / other channels / Email. The desktop updates
via SignalR or polling.
With Email — and with any channel when `Veriqa:LoginConfirmation:Mode` is `OnWebPage` — the sign-in
page then asks once more: confirm the sign-in there.
Check that you land authenticated and that the expected claims are present.
## Veriqa running elsewhere
Everything above is the **embedded** mode: Veriqa is a set of packages inside your own host. The
same server can instead run **separately**, and your application signs in against it over plain
OIDC — Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a
machine of your own. For a .NET application that changes the dependency tree, not the code: you
keep the client half of [step 5](#5-wire-up-the-client-application) and drop every Veriqa package,
so no Veriqa component enters your build or your SBOM.
**Veriqa Cloud is currently unavailable** — the cloud row below describes the contract of its
API. Use self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview).
| Mode | The `Authority` the client points at | Where the client is registered |
| - | - | - |
| Cloud | The project address, `https:///p/{project}` — read-only on the Project settings screen | The **Applications / OIDC clients** section of the console — see [Connecting to the cloud](/docs/cloud/connect) |
| Self-hosted | The address of your own server, e.g. `https://auth.your-domain.com` | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client) |
| Embedded (everything above) | The address of the host Veriqa sits in, e.g. `https://localhost:7300` | The same section of that host's own configuration — [step 4](#4-configure) |
The wiring is the one from [step 5](#5-wire-up-the-client-application), with a single difference:
the issuer address comes from configuration instead of code, because it is the one value that
changes between the modes and between environments.
```csharp Program.cs (the client application) theme={null}
builder.Services
.AddAuthentication(/* the two schemes of step 5 */)
.AddCookie()
.AddOpenIdConnect(options =>
{
// The only value that differs between cloud, self-hosted and embedded. Everything else —
// endpoints, keys, supported scopes — the client reads from discovery at startup.
options.Authority = builder.Configuration["Veriqa:Authority"];
options.ClientId = builder.Configuration["Veriqa:ClientId"];
options.ResponseType = "code";
options.UsePkce = true;
options.CallbackPath = "/signin-oidc";
options.AccessDeniedPath = "/signin-cancelled";
options.Events.OnRemoteFailure = context =>
{
context.HttpContext.RequestServices.GetRequiredService>()
.LogWarning(context.Failure, "The sign-in return carried an error.");
context.HandleResponse();
context.Response.Redirect("/signin-failed");
return Task.CompletedTask;
};
options.TokenValidationParameters.NameClaimType = "name";
});
```
```json appsettings.json (the client application) theme={null}
{
"Veriqa": {
"Authority": "https://auth.your-domain.com",
"ClientId": "my-app"
}
}
```
A confidential client adds `options.ClientSecret` — from user-secrets in development and a secret
store in production, never from `appsettings.json`. In the cloud the secret is issued by a separate
explicit action on the Applications screen and shown **once**.
Nothing else on this page becomes mode-specific: the endpoints of [step 7](#7-endpoints), the
claims of [step 8](#8-user-claims) and channel restriction through `acr_values`
([step 9](#9-channel-selection-via-acr-values)) are issuer behavior, and the client sees them
through discovery whichever mode it talks to.
**How the separate server is delivered** is a further choice, and it changes nothing on the client
side: a Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or an archive
installed as a systemd unit or a Windows service, with no Docker and no .NET runtime on the
machine — [Install as an OS service](/docs/quickstart/os-service).
## Next steps
Telegram, WhatsApp, other channels and Email — end to end.
Every section, key and default in one place.
Sign-in page design, localization, extension points.
What to check before shipping to production.
Where Veriqa sits in a standard OIDC flow.
The building blocks behind the connector.
The same issuer outside your build — in Docker or as an OS service.
# Java quickstart
Source: https://veriqa.app/docs/quickstart/java
Connecting a Java application to Veriqa over standard OpenID Connect — cloud, Docker or an OS service.
For non-.NET stacks Veriqa is an ordinary OpenID Connect provider. There is no Veriqa package for
Java, and none is needed: you plug in a standard OIDC client, and Veriqa handles all the specifics
(sign-in page, QR code, confirmation in the messenger) on its own side.
**One codebase, any deployment.** The application never depends on **where** Veriqa runs —
Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a machine
of your own. The deployments differ **only in the issuer address** and in where the client is
registered; the code below is identical for all of them, and switching comes down to
a single `issuer-uri` value.
## 1. Add the starters
The mainstream path for Java is **Spring Security OAuth2 Client** through the Spring Boot starter.
No version is needed: the Spring Boot BOM manages it. A second starter goes with it — the OAuth2 one
declares `spring-boot-starter`, Spring Security and the OAuth2/OIDC client, and **no web server**, so
on its own the application starts and exits without ever serving the callback:
```xml pom.xml theme={null}
org.springframework.bootspring-boot-starter-oauth2-clientorg.springframework.bootspring-boot-starter-web
```
```groovy build.gradle theme={null}
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
```
Together they bring everything required: the embedded Tomcat and Spring MVC the controller of
[step 5](#5-read-the-claims) needs, plus the OAuth2/OIDC client, callback handling and the Spring
Security integration.
## 2. Register a client on the Veriqa side
**Veriqa Cloud is currently unavailable** — the cloud rows describe its API contract. Use
self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview).
| Mode | Where the client is registered | What you get |
| - | - | - |
| Cloud | The **Applications / OIDC clients** section of the console. A default application already exists alongside the project — a **public client with PKCE, no secret and no `redirect_uri`**, so adding your `redirect_uri` comes first. The `issuer` address is on the Project settings screen, read-only | `issuer`, `client_id`, `redirect_uri` |
| Self-hosted | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client). The same section whichever way the server is delivered | The same, with values you choose |
**Self-hosted comes in two deliveries, and this page is the same for both.** The server runs in a
Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or is installed from an
archive as a systemd unit or a Windows service, with no Docker and no .NET runtime on the machine —
[Install as an OS service](/docs/quickstart/os-service). The choice decides how the server is delivered,
not the client registration, the issuer address or any of the code below.
**A secret is not mandatory.** The minimal working set is `issuer`, `client_id`, `redirect_uri`: a
public client with PKCE, and that is the cloud default. A confidential client is only needed when you
want to authenticate a server-side application with a secret; in the cloud the secret is issued by a
separate explicit action on the Applications screen and is shown **once**.
**The `redirect_uri` is matched exactly** — wildcards are not supported. Spring Security uses the
`{baseUrl}/login/oauth2/code/{registrationId}` template by default, so with `registrationId` =
`veriqa` and an application at `https://app.example.com` you must register exactly
`https://app.example.com/login/oauth2/code/veriqa` in Veriqa. This is the most common reason a
first sign-in is rejected.
For the application started as in [step 7](#7-checkpoint) that URL is
`http://127.0.0.1:8080/login/oauth2/code/veriqa` — register exactly that URL, and open the application at
the same host (`127.0.0.1`, not `localhost`): the callback URL is built from the host of the
incoming request, and the comparison on the Veriqa side is literal.
## 3. Describe the client registration
`issuer-uri` turns on discovery: Spring reads `/.well-known/openid-configuration` itself and finds
the remaining endpoints.
```yaml application.yml — public client (PKCE) theme={null}
spring:
security:
oauth2:
client:
registration:
veriqa:
client-id: my-app
client-authentication-method: none
authorization-grant-type: authorization_code
scope: openid, profile, channel
provider:
veriqa:
issuer-uri: https://auth.your-domain.com
```
```yaml application.yml — confidential client theme={null}
spring:
security:
oauth2:
client:
registration:
veriqa:
client-id: my-app
client-secret: ${VERIQA_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid, profile, channel
provider:
veriqa:
issuer-uri: https://auth.your-domain.com
```
**PKCE needs no separate switch.** Spring Security applies it automatically when
`client-authentication-method` is `none`; no extra configuration is necessary for a public client.
The secret is then not set at all — by leaving the key out, not by an empty string.
`issuer-uri` is the address [step 2](#2-register-a-client-on-the-veriqa-side) gave you — replace
`https://auth.your-domain.com` with it, and `client-id` with the client you registered there. Spring
validates the discovery document against this value at startup, so a stale placeholder fails the
start rather than the first sign-in.
Keep secrets in the environment (`${VERIQA_CLIENT_SECRET}`), not in a committed `application.yml`.
`${…}` is resolved by Spring from the environment of the process, so the confidential variant needs
the variable exported in the shell the application is started from:
```bash theme={null}
export VERIQA_CLIENT_SECRET="…" # confidential client only
```
The public variant reads no secret at all: with `client-authentication-method: none` the key is
absent from `application.yml`, not empty. If the variable is unset for the confidential one, the
start fails on the unresolved placeholder.
Switching between deployments — cloud, Docker, OS service — comes down to changing
`issuer-uri` alone.
## 4. Turn on sign-in
A minimal configuration is enough: `oauth2Login()` drives the whole flow — the redirect to the Veriqa
sign-in page, the callback handling and session creation.
```java SecurityConfig.java theme={null}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults());
return http.build();
}
}
```
From here Veriqa takes over: the user sees the sign-in page with a QR code and the channel list,
confirms the sign-in in a messenger on their phone, and the browser returns to your `redirect_uri`.
A user who clicks [**Cancel sign-in**](/docs/guides/customization#cancel-button) on that page comes back
with `error=access_denied`. `oauth2Login()` needs no code for it: a failed sign-in is redirected to
`/login?error`, the login page Spring Security generates.
## 5. Read the claims
The authenticated user is available as an `OidcUser`:
```java ProfileController.java theme={null}
@RestController
public class ProfileController {
@GetMapping("/")
public String home(@AuthenticationPrincipal OidcUser user) {
String subject = user.getSubject();
Instant confirmedAt = user.getAuthenticatedAt(); // the auth_time claim
String channelType = user.getClaimAsString("channel_type");
return "Hello, " + user.getFullName() + " (" + subject + ", " + channelType + ")";
}
}
```
This is the route the checkpoint of [step 7](#7-checkpoint) opens: it is behind
`anyRequest().authenticated()`, so the first visit starts the sign-in, and the answer after it shows
what the sign-in produced.
Veriqa adds the base set of claims to the tokens itself:
| Claim | Meaning | Where |
| - | - | - |
| `sub` | Stable subject identifier | both tokens |
| `auth_time` | When the confirmation completed — `getAuthenticatedAt()` | `id_token` only |
| `amr` | The channel type that confirmed the sign-in (`telegram`, `whatsapp`, `max`, `email`) | both tokens; an array of strings in the `id_token` |
| `name`, `email`, `phone_number`, … | From the resolved identity — depending on the requested scopes | both tokens |
| `picture` | The user's avatar — only with the `avatar` scope, also listed in `AllowedScopes` ([details](/docs/concepts/oidc-explainer#resolved-identity-and-claims)) | `access_token` only — read it from userinfo |
**Channel claims are issued only with the `channel` scope:**
| Claim | Meaning |
| - | - |
| `channel_type` | The channel type the user is linked through |
| `channel_user_id` | The user identifier inside that channel |
Without the requested `channel` scope these two claims end up in **no** token — and therefore do not
appear in the userinfo response either, which mirrors the access token. An application uses exactly
these claims to tell linked channels apart, so the scope is mandatory for account linking.
`amr` arrives in the `id_token` as an **array of strings** — that is how OIDC Core 1.0 §2 defines
it, and it stays an array even with a single authentication method. Read it from the ID token and
take the first element:
```java theme={null}
List amr = user.getIdToken().getClaimAsStringList("amr");
String channel = (amr == null || amr.isEmpty()) ? null : amr.get(0);
```
The same method is in the access token as well, so it also shows up in the `/connect/userinfo`
response — which Spring calls when `profile` is requested (as in the configuration above) and
merges with the `id_token` claims. In userinfo the value is a plain string rather than an array,
so `user.getClaimAsString("amr")` on the merged set depends on which claim won the merge; reading
through `getIdToken()` has no such fork.
For **linking a channel to an account** that is still not enough: `amr` names the channel type but
not the user identifier inside it. The identifier comes from `channel_user_id` under the `channel`
scope.
## 6. Restrict sign-in to a specific channel
The standard OIDC `acr_values` parameter in the `channel:{type}` form narrows the choice on the
sign-in page. In Spring Security it is added through an authorization request customizer:
```java SecurityConfig.java theme={null}
// The registration repository is provided by the starter — injecting it into the same class is enough
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
private OAuth2AuthorizationRequestResolver authorizationRequestResolver(
ClientRegistrationRepository clientRegistrationRepository) {
DefaultOAuth2AuthorizationRequestResolver resolver =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(customizer -> customizer
.additionalParameters(params -> params.put("acr_values", "channel:telegram")));
return resolver;
}
```
The resolver is then wired into the chain:
```java SecurityConfig.java theme={null}
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authorization -> authorization
.authorizationRequestResolver(
authorizationRequestResolver(this.clientRegistrationRepository))
)
);
```
Several space-separated values (`channel:telegram channel:whatsapp`) let the user pick from the
listed ones. Without the parameter every channel enabled on the server is available.
## 7. Checkpoint
Start the application from the project directory, in the shell where
[step 3](#3-describe-the-client-registration) exported the secret (the confidential variant only):
```bash Maven theme={null}
mvn spring-boot:run
```
```bash Gradle theme={null}
./gradlew bootRun
```
Spring Boot serves it on `http://127.0.0.1:8080` — the address the `redirect_uri` registered in
[step 2](#2-register-a-client-on-the-veriqa-side) is built from.
`curl https://auth.your-domain.com/.well-known/openid-configuration` — your `issuer-uri` in place
of the host — returns metadata with `https` URLs.
Open `http://127.0.0.1:8080/` — it is protected by `anyRequest().authenticated()`, so Spring
sends the browser to the Veriqa page with the QR code and the channel list.
Confirm the request in a trusted channel; the browser returns to
`http://127.0.0.1:8080/login/oauth2/code/veriqa` and Spring redirects it back to `/`.
`/` answers with the greeting of [step 5](#5-read-the-claims): `OidcUser` carries `sub`, and with
the `channel` scope requested `channel_type` is available on it.
## Next steps
Channel linking, re-confirmation and action approval — at the HTTP level.
Deploy your own issuer in Docker.
The same issuer without Docker — systemd or a Windows service, from an archive.
Where Veriqa fits into the standard flow.
What to check before shipping.
# Node.js quickstart
Source: https://veriqa.app/docs/quickstart/node
Connecting a Node.js application to Veriqa over standard OpenID Connect — cloud, Docker or an OS service.
For non-.NET stacks Veriqa is an ordinary OpenID Connect provider. There is no Veriqa package for
Node.js, and none is needed: you write a standard OIDC client, and Veriqa handles all the specifics
(sign-in page, QR code, confirmation in the messenger) on its own side.
**One codebase, any deployment.** The application never depends on **where** Veriqa runs —
Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a machine
of your own. The deployments differ **only in the issuer address** and in where the client is
registered; the code below is identical for all of them, and switching comes down to
a single environment variable.
## 1. Install the packages
The recommended OIDC library is [`openid-client`](https://github.com/panva/openid-client) (OpenID
Foundation certified, current line **6.x**). It brings neither a web framework nor a session store,
and the code below needs both:
```bash theme={null}
npm install openid-client express express-session
```
`express` is the framework the routes are written for; `express-session` is what gives them
`req.session` — where the PKCE `code_verifier` and the `state` are kept while the user is away.
Without either package, the first run fails on the import line with `ERR_MODULE_NOT_FOUND`, before
the application reaches Veriqa at all.
**Node.js 20 or newer** is required — that is the minimum declared by the library. The 6.x line is
a complete API rewrite: examples written for 5.x (`Issuer.discover`, `client.callback`) do **not**
apply to it.
**The example is ESM.** `config.js` awaits discovery at the top level of the module, which CommonJS
cannot do. Add `"type": "module"` to your `package.json` — without it `node server.js` fails with
`Cannot use import statement outside a module`.
## 2. Register a client on the Veriqa side
**Veriqa Cloud is currently unavailable** — the cloud rows describe its API contract. Use
self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview).
| Mode | Where the client is registered | What you get |
| - | - | - |
| Cloud | The **Applications / OIDC clients** section of the console. A default application already exists alongside the project — a **public client with PKCE, no secret and no `redirect_uri`**, so adding your `redirect_uri` comes first. The `issuer` address is on the Project settings screen, read-only | `issuer`, `client_id`, `redirect_uri` |
| Self-hosted | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client). The same section whichever way the server is delivered | The same, with values you choose |
**Self-hosted comes in two deliveries, and this page is the same for both.** The server runs in a
Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or is installed from an
archive as a systemd unit or a Windows service, with no Docker and no .NET runtime on the machine —
[Install as an OS service](/docs/quickstart/os-service). The choice decides how the server is delivered,
not the client registration, the issuer address or any of the code below.
Whichever you pick, the client must declare its allowed redirect URIs (matched **exactly**, wildcards
are not supported) and allowed scopes — including `channel` if the application needs the channel
claims (step 7).
The `redirect_uri` is compared literally, so register precisely the URL your callback route serves.
For the server of [step 6](#6-wire-it-into-a-server), started as in [step 9](#9-checkpoint), that URL
is `http://127.0.0.1:3000/callback` — register exactly that URL, and open the application at the same
host (`127.0.0.1`, not `localhost`).
**A secret is not mandatory.** The minimal working set is `issuer`, `client_id`, `redirect_uri`: a
public client with PKCE, and that is the cloud default. A confidential client is only needed when the
application is server-side and you want to authenticate it with a secret; in the cloud the secret is
issued by a separate explicit action on the Applications screen and is shown **once** — store it
right away.
Keep the values in the environment, not in code. Set them in the shell you will start the
application from — the first two are what step 2 gave you, the last two are yours:
```bash theme={null}
export VERIQA_ISSUER="https://auth.your-domain.com" # the issuer address
export VERIQA_CLIENT_ID="my-app" # the client you registered
export VERIQA_CLIENT_SECRET="…" # confidential client only
export APP_REDIRECT_URI="http://127.0.0.1:3000/callback"
export SESSION_SECRET="$(openssl rand -hex 32)"
```
`SESSION_SECRET` is not issued by Veriqa — it is a random string of your own, the key
`express-session` signs the session cookie with. Keep it out of version control and use a separate
value in every environment. The public variant needs no `VERIQA_CLIENT_SECRET`: a public client has
no secret at all. If a variable your variant reads is missing, the run fails while
`config.js` is still being imported — `new URL(undefined)` throws `ERR_INVALID_URL` — before any
route is served.
A `.env` file works just as well if you prefer one: Node.js 20.6 and newer reads it with
`node --env-file=.env server.js`.
Switching between deployments — cloud, Docker, OS service — comes down to changing
`VERIQA_ISSUER` alone.
In the cloud the `issuer` is derived from the project slug. Changing the slug changes the issuer and
**breaks existing integrations** — it is a separate owner action in the Dangerous zone, not a
routine settings edit.
## 3. Run discovery
The client is built once at process start: the library reads
`/.well-known/openid-configuration` itself and remembers the endpoints.
```js config.js — public client (PKCE) theme={null}
import * as client from 'openid-client'
const issuer = new URL(process.env.VERIQA_ISSUER)
// The fourth argument is the client authentication method.
// A public client has none: only client_id goes to the token endpoint.
export const config = await client.discovery(
issuer,
process.env.VERIQA_CLIENT_ID,
undefined,
client.None(),
)
```
```js config.js — confidential client theme={null}
import * as client from 'openid-client'
const issuer = new URL(process.env.VERIQA_ISSUER)
export const config = await client.discovery(
issuer,
process.env.VERIQA_CLIENT_ID,
process.env.VERIQA_CLIENT_SECRET,
)
```
The first argument is a `URL` object, not a string. If discovery returns `http` endpoint addresses
while you access it over `https`, the client is not at fault: the self-hosted server sits behind a
proxy with no trusted headers — see
[step 6 of the self-hosted quickstart](/docs/quickstart/self-hosted#6-put-it-behind-a-reverse-proxy).
## 4. Start the sign-in
PKCE is mandatory and `state` protects against CSRF. Both values must be **stored in the session**
until the user comes back: they are required in the callback.
```js login.js theme={null}
import * as client from 'openid-client'
import { config } from './config.js'
export async function startLogin(req, res) {
const code_verifier = client.randomPKCECodeVerifier()
const code_challenge = await client.calculatePKCECodeChallenge(code_verifier)
const state = client.randomState()
// Store both values in the user session — the callback requires them
req.session.code_verifier = code_verifier
req.session.state = state
const parameters = {
redirect_uri: process.env.APP_REDIRECT_URI,
scope: 'openid profile channel',
code_challenge,
code_challenge_method: 'S256',
state,
}
res.redirect(client.buildAuthorizationUrl(config, parameters).href)
}
```
From here Veriqa takes over: the user sees the sign-in page with a QR code and the channel list,
confirms the sign-in in a messenger on their phone, and the browser returns to your `redirect_uri`
with a code.
## 5. Handle the return
```js callback.js theme={null}
import * as client from 'openid-client'
import { config } from './config.js'
export async function handleCallback(req, res) {
const currentUrl = new URL(req.url, process.env.APP_REDIRECT_URI)
let tokens
try {
tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: req.session.code_verifier,
expectedState: req.session.state,
})
} catch (err) {
// The user did not go through with the sign-in: the answer carries error=access_denied
// instead of a code. That is an answer, not a fault.
if (err instanceof client.AuthorizationResponseError && err.error === 'access_denied') {
res.redirect('/signin-cancelled')
return
}
throw err
}
const claims = tokens.claims() // the parsed id_token
req.session.user = {
sub: claims.sub,
name: claims.name,
channel_type: claims.channel_type, // only with the channel scope (step 7)
}
res.redirect('/')
}
```
`authorizationCodeGrant` exchanges the code for tokens and validates the response itself; `state` is
checked through `expectedState`, so you do not verify it again by hand. The `claims()` method returns
the parsed `id_token`, or `undefined` if the server issued none.
The `catch` branch handles a sign-in that did not complete. The sign-in page has a
[**Cancel sign-in** button](/docs/guides/customization#cancel-button), on by default; a user who clicks
it comes back to `/callback` with `error=access_denied` and no code. `authorizationCodeGrant`
answers an error response by throwing `AuthorizationResponseError` — without the branch the return
ends in an unhandled exception instead of a page of your application.
`/` and `/signin-cancelled` are where the callback finally lands the browser — the routes
[step 6](#6-wire-it-into-a-server) adds.
## 6. Wire it into a server
The three modules above are handlers, not an application: something has to give them `req.session`,
put them on routes and listen on a port. This is that file, and it is the one you start.
```js server.js theme={null}
import express from 'express'
import session from 'express-session'
import { startLogin } from './login.js'
import { handleCallback } from './callback.js'
const app = express()
// express-session is what puts req.session in place — the PKCE code_verifier and the
// state live there while the user is confirming on the phone. The default store keeps
// sessions in the memory of one process: enough for this walkthrough, not for production.
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}))
app.get('/login', startLogin)
app.get('/callback', handleCallback)
app.get('/', (req, res) => {
if (req.session.user === undefined) {
res.redirect('/login')
return
}
res.json(req.session.user)
})
// Where the callback sends a user who did not go through with the sign-in. A route of its own
// because "/" sends an anonymous visitor to /login — straight back to the page they just left.
app.get('/signin-cancelled', (req, res) => {
res.send('Sign-in was cancelled. Open / to sign in again.')
})
app.listen(3000, '127.0.0.1')
```
The route paths are the contract of the page: `/callback` is what
`APP_REDIRECT_URI` points at and what you registered in
[step 2](#2-register-a-client-on-the-veriqa-side), and `/` shows what the sign-in produced — with
nothing in the session yet it sends you to `/login`, and after a completed sign-in it answers with
the stored claims as JSON, so the outcome is visible without a template.
## 7. Read the claims
Veriqa adds the base set of claims to the tokens itself:
| Claim | Meaning | Where |
| - | - | - |
| `sub` | Stable subject identifier | both tokens |
| `auth_time` | When the confirmation completed (Unix) | `id_token` only |
| `amr` | The channel type that confirmed the sign-in (`telegram`, `whatsapp`, `max`, `email`) | both tokens; an array of strings in the `id_token` (`["telegram"]`), even for a single method |
| `name`, `email`, `phone_number`, … | From the resolved identity — depending on the requested scopes (`profile`, `email`, `phone`) | both tokens |
| `picture` | The user's avatar — only with the `avatar` scope, also listed in `AllowedScopes` ([details](/docs/concepts/oidc-explainer#resolved-identity-and-claims)) | `access_token` only — read it from userinfo |
**Channel claims are issued only with the `channel` scope:**
| Claim | Meaning |
| - | - |
| `channel_type` | The channel type the user is linked through |
| `channel_user_id` | The user identifier inside that channel |
Without the requested `channel` scope these two claims end up in **no** token — and therefore do not
appear in the userinfo response either, which mirrors the access token. An application uses exactly
these claims to tell linked channels apart, so the scope is mandatory for account linking.
```js theme={null}
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub)
// userinfo.channel_type / userinfo.channel_user_id — present only if the channel scope was requested
```
The third argument is the expected `sub`: the library checks it against the response and will not let
the subject be swapped.
## 8. Restrict sign-in to a specific channel
The standard OIDC `acr_values` parameter in the `channel:{type}` form narrows the choice on the
sign-in page:
```js theme={null}
const parameters = {
redirect_uri: process.env.APP_REDIRECT_URI,
scope: 'openid profile',
acr_values: 'channel:telegram',
code_challenge,
code_challenge_method: 'S256',
state,
}
```
Several space-separated values (`channel:telegram channel:whatsapp`) let the user pick from the
listed ones. Without the parameter every channel enabled on the server is available.
## 9. Checkpoint
Start the application from the directory holding `server.js`, in the shell where the variables of
[step 2](#2-register-a-client-on-the-veriqa-side) are exported:
```bash theme={null}
node server.js
```
It serves on `http://127.0.0.1:3000` — the address `APP_REDIRECT_URI` is built from.
`curl $VERIQA_ISSUER/.well-known/openid-configuration` returns metadata with `https` URLs.
Open `http://127.0.0.1:3000/login` — the browser should land on the Veriqa page with the QR code
and the channel list.
Confirm the request in a trusted channel; the browser returns to `/callback`, which redirects it
to `/`.
`/` answers with the session as JSON: `sub` is filled in, and `channel_type` carries the channel
that confirmed the sign-in — it is absent if the `channel` scope was not requested.
## Next steps
Channel linking, re-confirmation and action approval — at the HTTP level.
Deploy your own issuer in Docker.
The same issuer without Docker — systemd or a Windows service, from an archive.
Where Veriqa fits into the standard flow.
What to check before shipping.
# Install as an OS service
Source: https://veriqa.app/docs/quickstart/os-service
Install the Veriqa auth server from an archive as a systemd unit or a Windows service — no Docker, no .NET on the machine.
This is the same standalone auth server as the [self-hosted quickstart](/docs/quickstart/self-hosted),
delivered without Docker: an archive with a **self-contained** executable plus an installer that
registers it as a systemd unit or a Windows service. The target machine needs no .NET runtime.
What this page covers is the **installation** — where the files live, how the service is registered,
how it is updated and removed. What to put into the configuration (stores, certificates, clients,
channels) does not change between delivery modes and is covered in the
[self-hosted quickstart](/docs/quickstart/self-hosted).
**The installer leaves you with a running service, not with a production one.** It writes a
startup configuration — an in-memory store and two self-signed token certificates — so that the
service answers `/health/live` straight away instead of refusing to start.
[Step 6](#6-move-to-production) covers everything that makes it a production instance.
## 1. Download the archive and check it
A release carries three archives and one checksum file:
| File | What it is |
| - | - |
| `veriqa-authserver--linux-x64.tar.gz` | Linux, x86-64 |
| `veriqa-authserver--linux-arm64.tar.gz` | Linux, ARM64 |
| `veriqa-authserver--win-x64.zip` | Windows, x86-64 |
| `veriqa-authserver--SHA256SUMS.txt` | SHA-256 sums of the three archives |
They are release assets, and the base of their address is stable — `permalink/latest` always
resolves to the newest release:
```
/-/releases/permalink/latest/downloads/veriqa-authserver--linux-x64.tar.gz
```
The file name carries the version, so the address as a whole is **not** a permanent link: once the
next release is out, a path with the previous version is no longer among the assets
`permalink/latest` resolves to, and the download answers 404. Take the repository address and the
current version from [veriqa.app/source](https://veriqa.app/source) each time instead of keeping a
ready-made URL:
```bash theme={null}
VERIQA_VERSION=0.6.0 # version of the release you are installing
VERIQA_DOWNLOADS=… # …/-/releases/permalink/latest/downloads, from veriqa.app/source
curl -fLO "$VERIQA_DOWNLOADS/veriqa-authserver-$VERIQA_VERSION-linux-x64.tar.gz"
curl -fLO "$VERIQA_DOWNLOADS/veriqa-authserver-$VERIQA_VERSION-SHA256SUMS.txt"
sha256sum --ignore-missing -c "veriqa-authserver-$VERIQA_VERSION-SHA256SUMS.txt"
```
`--ignore-missing` is what makes this work with a single archive downloaded: the sums file lists all
three, and without the flag `sha256sum` fails on the two you did not download.
On Windows:
```powershell theme={null}
$version = "0.6.0"
$expected = (Select-String -Path ".\veriqa-authserver-$version-SHA256SUMS.txt" -Pattern "win-x64\.zip").Line.Split(" ")[0]
(Get-FileHash ".\veriqa-authserver-$version-win-x64.zip" -Algorithm SHA256).Hash -eq $expected
```
`True` means the archive is intact.
## 2. Install on Linux
The archive unpacks into a single `veriqa-authserver/` directory and carries its own installer,
`install.sh`. Run it as root **from that directory**:
```bash theme={null}
tar -xzf "veriqa-authserver-$VERIQA_VERSION-linux-x64.tar.gz"
cd veriqa-authserver
sudo ./install.sh
```
The installer creates the system user `veriqa` (no login shell, no home directory created), copies
the program, creates the configuration and data directories, generates the token certificates,
writes the startup configuration, installs the unit file and enables the service. It does **not**
start it: the startup configuration is meant to be read before the first start, not after it.
| Option | Default | What it sets |
| - | - | - |
| `--install-directory ` | `/opt/veriqa` | Program directory |
| `--config-directory ` | `/etc/veriqa` | Configuration directory |
| `--urls ` | `http://127.0.0.1:8080` | Addresses the service listens on |
| `--service-name ` | `veriqa` | Name of the systemd unit |
**The program directory belongs to this installation alone.** Both installers write into it
without clearing it out first — a file of the archive overwrites its counterpart, anything else
stays where it is — and removal is the mirror image: `--uninstall` / `-Uninstall` takes away the
files the installer copied and nothing else, and keeps the directory itself if something else is
still in there. Point `--install-directory` / `-InstallDirectory` at a directory of its own and the
installation comes off in one step.
The default `--urls` is **loopback only** — the instance is reachable from the same machine and from
a reverse proxy on it, and from nowhere else.
**TLS is needed for the API itself, not only to open the instance up.** Over plain HTTP the token
endpoint answers `400 invalid_request` — "This server only accepts HTTPS requests"
([ID2083](https://documentation.openiddict.com/errors/ID2083)) — so a backend calling the
confirmation API gets no token, even from the same machine. Do [step 7](#7-https) before the first
call: put a reverse proxy in front, or give Kestrel a certificate.
Start the service when you have read what the installer printed:
```bash theme={null}
sudo systemctl start veriqa
systemctl status veriqa
```
The installer refuses to run over a service that is already registered under the same name
(`use --uninstall first`) rather than recreate it silently. Updating an existing installation is
covered in [Updating](#updating).
## 3. Install on Windows
Unpack the `.zip` and run `install.ps1` from the unpacked directory in an **elevated** PowerShell
(Windows PowerShell 5.1 and PowerShell 7 both work):
```powershell theme={null}
Expand-Archive ".\veriqa-authserver-$version-win-x64.zip" -DestinationPath .
cd .\veriqa-authserver
.\install.ps1
```
| Parameter | Default | What it sets |
| - | - | - |
| `-InstallDirectory ` | `%ProgramFiles%\Veriqa` | Program directory |
| `-Urls ` | `http://127.0.0.1:8080` | Addresses the service listens on |
| `-ServiceName ` | `Veriqa` | Name of the Windows service |
The service is registered with automatic start under the virtual account
`NT SERVICE\` — no account and no password of your own. The configuration directory
is `%ProgramData%\Veriqa`; its access list is rebuilt without inheritance, so the `Users` group gets
no access to the secrets kept there. As on Linux, the service is registered but left stopped:
```powershell theme={null}
Start-Service Veriqa
Get-Service Veriqa
```
## 4. What the first start gives you
The installation is deliberately complete enough to start and no more:
* the OpenIddict store is `InMemory` — registered clients, issued tokens and the journal are **lost
on every restart**;
* the token certificates are self-signed, RSA-2048, valid for two years, and were generated on this
machine;
* no channel and no OIDC client is configured, so a real sign-in cannot complete yet.
Both health endpoints answer 200 in this state:
```bash theme={null}
curl -f http://127.0.0.1:8080/health/live
curl -f http://127.0.0.1:8080/health/ready
```
`/health/ready` is 200 because the startup configuration declares no external dependency to check —
not because the instance is ready for production.
## 5. Where the files are
The configuration, the data-protection keys and the token certificates live **outside** the program
directory, so that replacing the program never touches them.
| What | Linux | Windows |
| - | - | - |
| Program | `/opt/veriqa` | `%ProgramFiles%\Veriqa` |
| Configuration | `/etc/veriqa/appsettings.Production.json` | `%ProgramData%\Veriqa\appsettings.Production.json` |
| Sample production configuration | `/opt/veriqa/appsettings.Production.sample.json` | `%ProgramFiles%\Veriqa\appsettings.Production.sample.json` |
| Data-protection keys | `/var/lib/veriqa/keys` | `%ProgramData%\Veriqa\keys` |
| Token certificates | `/var/lib/veriqa/certs` | `%ProgramData%\Veriqa\certs` |
| Service definition | `/etc/systemd/system/veriqa.service` | Registry key of the `Veriqa` service |
On Linux `/etc/veriqa` is `root:veriqa` `0750` and `/var/lib/veriqa` is `0700` owned by `veriqa`:
they hold secrets, and only the service account reads them.
**Why the keys directory is not optional.** Data-protection keys protect the sign-in cookies and
every value the host encrypts. With no keys directory and no Redis the host keeps them in memory, and
every restart invalidates what the previous process issued — users are thrown out of sign-ins that
were in flight. The installer therefore sets `Veriqa:DataProtection:KeysDirectory` in the environment
of the service itself, and the directory survives an update and an ordinary uninstall.
The service is registered with four environment variables — the environment is where they belong,
because they say where the configuration is read from:
| Variable | Linux | Windows |
| - | - | - |
| `ASPNETCORE_ENVIRONMENT` | `Production` | `Production` |
| `ASPNETCORE_URLS` | value of `--urls` | value of `-Urls` |
| `VERIQA_CONFIG_DIRECTORY` | `/etc/veriqa` | `%ProgramData%\Veriqa` |
| `Veriqa__DataProtection__KeysDirectory` | `/var/lib/veriqa/keys` | `%ProgramData%\Veriqa\keys` |
### The configuration directory
`VERIQA_CONFIG_DIRECTORY` names a directory, not a file. From it the host reads `appsettings.json`
and `appsettings.Production.json`, both optional, and layers them **over** the files shipped with the
program. Precedence, weakest to strongest:
1. `appsettings.json` of the program directory;
2. `appsettings.Production.json` of the program directory;
3. `appsettings.json` of the configuration directory;
4. `appsettings.Production.json` of the configuration directory;
5. environment variables (`Veriqa__OpenIddict__…`);
6. command-line arguments.
That is why the one line the installer writes — `Provider` = `InMemory` — wins over the
`Provider` = `PostgreSQL` that ships in the program directory, and why anything you pass through the
environment still wins over both.
If the variable names a directory that does not exist, the host refuses to start and says so:
`VERIQA_CONFIG_DIRECTORY points to '/etc/veriqa', which does not exist.` A directory that exists but
holds no files is not an error.
The self-hosted quickstart keeps the integrator's own settings in `config/veriqa.json`, resolved
against the content root. For a service the content root is the **program** directory, which an
update replaces — so in this delivery mode put your settings into the configuration directory
instead.
## 6. Move to production
The installation ships a template next to the program:
`appsettings.Production.sample.json`. It carries the same keys as the self-hosted quickstart, with
`REPLACE_ME` placeholders for the values. Use it as a **reference** and edit the configuration file
the installer wrote, rather than overwriting that file with it: the template carries no paths to the
certificates generated on this machine, and every `REPLACE_ME` in it is a value you still have to
supply.
```bash theme={null}
sudo cp /etc/veriqa/appsettings.Production.json /root/appsettings.Production.json.bak
sudo nano /etc/veriqa/appsettings.Production.json # reference: /opt/veriqa/appsettings.Production.sample.json
sudo systemctl restart veriqa
```
Keep that copy **outside** `/etc/veriqa`: from the edit below on it holds secrets — a bot token,
connection strings, certificate passwords — and [full removal](#removing) neither takes it away on
Linux nor spares it on Windows, where `%ProgramData%\Veriqa` is deleted entirely.
What to write there is documented once, for both delivery modes:
* **stores** — a relational provider and its connection string, and the audit sink:
[self-hosted, step 2](/docs/quickstart/self-hosted#2-configure-it);
* **token certificates** — real ones instead of the self-signed pair the installer generated:
[self-hosted, step 3](/docs/quickstart/self-hosted#3-provide-the-token-certificates);
* **OIDC clients** — [self-hosted, step 4](/docs/quickstart/self-hosted#4-declare-your-client);
* **channels** — [self-hosted, step 7](/docs/quickstart/self-hosted#7-enable-channels), and
[Channel setup](/docs/guides/channels) end to end.
Replacing the token certificates invalidates every token signed with the old pair, and switching
the store from `InMemory` to a database starts from an empty one: the clients registered in memory
have to be declared in the configuration. Do both before the instance carries real sign-ins.
### SQL Server on Windows
The Windows archive does not include `Microsoft.Data.SqlClient.SNI.dll`, the native network library
the SQL Server driver uses on Windows: it is Microsoft's, under the Microsoft Software License Terms,
and Veriqa does not redistribute it. PostgreSQL needs nothing, and neither does Linux. With
`SqlServer` selected for any store and the library missing, the service refuses to start, and the
message names the `Microsoft.Data.SqlClient` version and links its nuget.org page.
Take the library from the NuGet package `Microsoft.Data.SqlClient.SNI.runtime`, in the version listed
under **Dependencies** on that page, and put it next to the program. By downloading it, you accept
Microsoft's terms for it:
```powershell theme={null}
$sniVersion = "…" # from the Dependencies of Microsoft.Data.SqlClient on nuget.org
$package = "microsoft.data.sqlclient.sni.runtime"
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/$package/$sniVersion/$package.$sniVersion.nupkg" -OutFile "$env:TEMP\sni.zip"
Expand-Archive "$env:TEMP\sni.zip" -DestinationPath "$env:TEMP\sni" -Force
Copy-Item "$env:TEMP\sni\runtimes\win-x64\native\Microsoft.Data.SqlClient.SNI.dll" "$env:ProgramFiles\Veriqa\"
Restart-Service Veriqa
```
The installer removes only the files it put in the program directory, so the library stays there
through an [update](#updating); after one, check that the version the new release's driver lists is
still the one you copied. On [full removal](#removing) delete it by hand.
## 7. HTTPS
The host speaks HTTP on the addresses of `--urls` / `-Urls`, and OpenIddict serves `/connect/token`
and the rest of the protocol **only over `https`** — so this step is not optional, whether or not the
instance is reachable from outside. There are two ways to put TLS in front of it.
**(a) A reverse proxy on the same machine.** Leave `--urls` at `http://127.0.0.1:8080`, terminate TLS
in NGINX / Caddy / IIS and forward to loopback. For OpenIddict to build `https` URLs the proxy must
forward `X-Forwarded-Proto` and `X-Forwarded-For`, and the proxy must be trusted — the same
`ForwardedHeaders` section as in
[self-hosted, step 6](/docs/quickstart/self-hosted#6-put-it-behind-a-reverse-proxy), in the configuration
file of the installation:
```json appsettings.Production.json theme={null}
{
"ForwardedHeaders": {
"KnownProxies": [ "127.0.0.1" ]
}
}
```
Only loopback is trusted by default, so a proxy on the same machine is covered even without this
section — writing it out keeps the trust boundary visible in the configuration. A proxy on **another**
host is not covered: put its address here instead, or its network in `KnownIPNetworks`. The proxy is
also where per-IP rate limiting belongs — see
[Production hardening](/docs/guides/hardening#rate-limiting-and-anti-abuse).
[Behind a reverse proxy](/docs/guides/reverse-proxy) covers the paths it has to pass and the WebSocket of the sign-in page.
**(b) A certificate in Kestrel**, with no proxy at all. This is plain ASP.NET Core configuration in
the same file ([Kestrel endpoints](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/endpoints)):
```json appsettings.Production.json theme={null}
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:8443",
"Certificate": {
"Path": "/var/lib/veriqa/certs/server.pfx",
"Password": "…"
}
}
}
}
}
```
The `Kestrel:Endpoints` section **replaces** the addresses of `ASPNETCORE_URLS` rather than adding to
them, so no reinstall is needed — but the HTTP endpoint the installer set through `--urls` is gone
with it. Declare an `Http` endpoint alongside if you still want one (for local health probes, for
instance). Keep the server certificate readable by the service account only, next to the token
certificates.
## 8. Verify
```bash theme={null}
curl -f http://127.0.0.1:8080/health/live # the process is up
curl -f http://127.0.0.1:8080/health/ready # configured dependencies answer
curl https://auth.your-domain.com/.well-known/openid-configuration
```
Discovery must come back with `https` URLs; if it comes back with `http`, the proxy is not trusted —
[step 7](#7-https). The full checklist of a deployed instance, including the first sign-in, is in
[self-hosted, step 8](/docs/quickstart/self-hosted#8-verify).
On Linux the service log is in the journal:
```bash theme={null}
journalctl -u veriqa -n 200 --no-pager
```
On Windows a failed start is recorded by the Service Control Manager in Event Viewer → Windows Logs.
To see the message of the host itself, run the executable in a console **from the program
directory**, with the same environment:
```powershell theme={null}
$env:ASPNETCORE_ENVIRONMENT = "Production"
$env:VERIQA_CONFIG_DIRECTORY = "$env:ProgramData\Veriqa"
# Started by hand, the content root is the working directory — `appsettings*.json` and `wwwroot`
# are read from there, so step into the program directory instead of running it by full path.
Set-Location "$env:ProgramFiles\Veriqa"
.\Veriqa.Core.AuthServer.Host.exe
```
## Updating
An update replaces the **program** and keeps the configuration, the data-protection keys and the
token certificates: they live outside the program directory on purpose.
```bash theme={null}
sudo ./install.sh --uninstall # from the unpacked archive of the installed version
tar -xzf "veriqa-authserver-$VERIQA_VERSION-linux-x64.tar.gz" # the new version
cd veriqa-authserver && sudo ./install.sh
sudo systemctl start veriqa
```
On Windows the same two steps: `.\install.ps1 -Uninstall` from the unpacked archive of the installed
version, then `.\install.ps1` from the new archive, then `Start-Service Veriqa`.
The second run does not overwrite an existing `appsettings.Production.json` and does not regenerate
existing certificates — it reports that it kept them. If you installed with non-default
`--service-name`, `--install-directory` or `--config-directory`, pass the same values to both runs.
`--urls` / `-Urls` has to be repeated on the installing run as well, and for a different reason: it
is not kept anywhere to be read back. The installer writes the unit file — on Windows, the
environment of the service — anew every time, so an install without it puts the service back on the
default `http://127.0.0.1:8080` and the instance stops answering anything but loopback.
## Removing
**Ordinary removal** takes away the service and the program and **keeps** the configuration, the
data-protection keys and the certificates, so that reinstalling over them keeps current sign-ins and
secrets:
```bash theme={null}
sudo ./install.sh --uninstall
```
```powershell theme={null}
.\install.ps1 -Uninstall
```
Run it **from the unpacked archive of the installed version**: the installer reads that archive to
know which files under the program directory are its own, and takes away those. The program
directory goes with them once it is empty; if anything else is left in it, the directory stays, and
the installer says so instead of clearing it out. Without the archive next to it the installer
removes the service and warns that the program files could not be told apart — it does not guess.
**Full removal** also deletes the configuration the installer wrote, the data directory and (on
Linux) the `veriqa` user:
```bash theme={null}
sudo ./install.sh --uninstall --remove-data
```
```powershell theme={null}
.\install.ps1 -Uninstall -RemoveData
```
Full removal makes **every issued cookie invalid** — the data-protection keys are gone with the
directory — and the tokens signed with the removed certificates stop being accepted. Copy
`/etc/veriqa/appsettings.Production.json` (or `%ProgramData%\Veriqa\appsettings.Production.json`)
somewhere safe first if this installation is meant to come back. **Somewhere safe means outside
those directories**: a copy left next to the configuration is not a backup — on Windows it goes
with `%ProgramData%\Veriqa`, and on Linux it stays behind, secrets and all, because the directory
survives and the installer does not name what is left in it.
On Linux `/var/lib/veriqa` is deleted entirely, while `/etc/veriqa` loses the
`appsettings.Production.json` the installer wrote and is removed only when nothing else is left in
it. On Windows all three live in `%ProgramData%\Veriqa`, which is deleted entirely.
`--remove-data` / `-RemoveData` without the uninstall switch is refused, and nothing is changed.
Removing twice is safe: with no service left, the installer says so and takes away what is left of
the installation.
## Common failures
| Symptom | Cause | What to do |
| - | - | - |
| A backend gets `400 invalid_request`, "This server only accepts HTTPS requests", from `/connect/token` | The instance is called over plain HTTP: OpenIddict serves the protocol over `https` only | Put TLS in front of it, or make the proxy trusted so that `X-Forwarded-Proto` is read — [step 7](#7-https) |
| Windows: the service does not start, the log says `Microsoft.Data.SqlClient.SNI.dll` is not found | `SqlServer` is selected for a store, and the archive does not ship Microsoft's native library for it | Put the library next to the program — [SQL Server on Windows](#sql-server-on-windows) |
| After a restart the registered clients and the issued tokens are gone | The startup configuration is still in place: `Veriqa:OpenIddict:Database:Provider` = `InMemory` is the volatile store | Move to a relational provider — [step 6](#6-move-to-production) and [self-hosted, step 2](/docs/quickstart/self-hosted#2-configure-it) |
| The service is active, but every restart throws users out of their sign-ins | The host keeps the data-protection keys in memory: `Veriqa:DataProtection:KeysDirectory` is not in effect (the environment of the service was edited, or the host runs outside the service manager) | Check the four variables of [step 5](#5-where-the-files-are); `systemctl show veriqa -p Environment` on Linux |
| The sign-in page shows untranslated texts | The content root is not the program directory. Under a service manager it always is; started by hand from another directory it is the working directory, and `wwwroot/locales` is read relative to the content root | Start the service through `systemctl` / `Start-Service`, or `cd` into the program directory first |
| Discovery returns `http` URLs, `/connect/authorize` answers `invalid_request` | The proxy is not trusted, so `X-Forwarded-Proto` is ignored | Add the proxy to `ForwardedHeaders:KnownProxies` — [step 7](#7-https) |
| After an update the instance answers on loopback only, though the service is active | The installing run was made without `--urls` / `-Urls`: the unit file (on Windows, the environment of the service) was written anew with the default `http://127.0.0.1:8080` | Reinstall with the address in use: `./install.sh --uninstall`, then `./install.sh --urls ` from the archive — [Updating](#updating) |
| `Access denied` on the keys directory | The directory was created by someone other than the service account — by hand as root, or under a different `--service-name` / `-ServiceName`, whose virtual account is a different one on Windows | Linux: `chown -R veriqa:veriqa /var/lib/veriqa`. Windows: `.\install.ps1 -Uninstall -ServiceName `, then `.\install.ps1 -ServiceName ` from the archive — both runs need the name the service was installed under (and the same `-InstallDirectory`, if the installation used one), otherwise they act on the default `Veriqa` service and `%ProgramFiles%\Veriqa` instead. The reinstall rebuilds the access lists and keeps the configuration, the keys and the certificates (over a registered service the installer refuses to run) |
Failures that stop the host **at startup** — a missing certificate, an incomplete client, a channel
without a token, an unknown provider value — are the same in both delivery modes and are listed in
[self-hosted, step 9](/docs/quickstart/self-hosted#9-common-first-run-failures). On Linux the message is in
`journalctl -u veriqa`.
## Building the archives yourself
For the release archives the repository carries a pair of scripts that behave identically —
`build/publish-host.sh` and `build/publish-host.ps1`:
```bash theme={null}
./build/publish-host.sh # all three RIDs, Release
./build/publish-host.sh --rid linux-x64 # one of them
```
They publish the host self-contained and single-file for `linux-x64`, `linux-arm64` and `win-x64`,
pack each result together with the installer files of its OS, and leave the archives and
`veriqa-authserver--SHA256SUMS.txt` in `artifacts/host/`. The version comes from
`build/Versions.props`. What comes out is what this page installs — the same layout, the same
`appsettings.Production.sample.json`, and no `appsettings.Development.json`.
## Next steps
Stores, certificates, clients and channels — the configuration itself.
Telegram, WhatsApp, other channels and Email — end to end.
Every section, key and default in one place.
What to check before shipping.
The paths to pass, the WebSocket and an NGINX configuration.
Ask for what the channel did not give, inside the same sign-in.
# Python quickstart
Source: https://veriqa.app/docs/quickstart/python
Connecting a Python application to Veriqa over standard OpenID Connect — cloud, Docker or an OS service.
For non-.NET stacks Veriqa is an ordinary OpenID Connect provider. There is no Veriqa package for
Python, and none is needed: you plug in a standard OIDC client, and Veriqa handles all the specifics
(sign-in page, QR code, confirmation in the messenger) on its own side.
**One codebase, any deployment.** The application never depends on **where** Veriqa runs —
Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a machine
of your own. The deployments differ **only in the issuer address** and in where the client is
registered; the code below is identical for all of them, and switching comes down to
a single `server_metadata_url`.
## 1. Install the library
The recommended library is [Authlib](https://docs.authlib.org/) (current line **1.8.x**):
```bash theme={null}
pip install authlib flask requests
```
Authlib pulls in neither the web framework nor an HTTP client. `flask` is the framework the example
below is built on; `requests` is what Authlib's Flask and Django integrations are built on — the
Starlette and FastAPI ones use `httpx` instead, so install the one your framework needs. Without either
package, the very first run fails on the import block at the top of `app.py` with
`ModuleNotFoundError`, before the application reaches Veriqa at all.
**Python 3.10 or newer** is required — that is the minimum declared by the package. Ready-made
integrations exist for Flask, Django, Starlette and FastAPI; Flask is shown below, the others use the
same `OAuth` registry and the same calls.
## 2. Register a client on the Veriqa side
**Veriqa Cloud is currently unavailable** — the cloud rows describe its API contract. Use
self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview).
| Mode | Where the client is registered | What you get |
| - | - | - |
| Cloud | The **Applications / OIDC clients** section of the console. A default application already exists alongside the project — a **public client with PKCE, no secret and no `redirect_uri`**, so adding your `redirect_uri` comes first. The `issuer` address is on the Project settings screen, read-only | `issuer`, `client_id`, `redirect_uri` |
| Self-hosted | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client). The same section whichever way the server is delivered | The same, with values you choose |
**Self-hosted comes in two deliveries, and this page is the same for both.** The server runs in a
Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or is installed from an
archive as a systemd unit or a Windows service, with no Docker and no .NET runtime on the machine —
[Install as an OS service](/docs/quickstart/os-service). The choice decides how the server is delivered,
not the client registration, the issuer address or any of the code below.
**A secret is not mandatory.** The minimal working set is `issuer`, `client_id`, `redirect_uri`: a
public client with PKCE, and that is the cloud default. A confidential client is only needed when you
want to authenticate a server-side application with a secret; in the cloud the secret is issued by a
separate explicit action on the Applications screen and is shown **once**.
The `redirect_uri` is matched **exactly** — wildcards are not supported. Register precisely the URL
your callback route serves. For the Flask example below, started as in
[step 7](#7-checkpoint), that URL is `http://127.0.0.1:5000/authorize` — register exactly that URL, and
open the application at the same host (`127.0.0.1`, not `localhost`): the callback URL is
built by `url_for(..., _external=True)` from the host of the incoming request, and the comparison
on the Veriqa side is literal.
## 3. Register the provider
`server_metadata_url` turns on discovery: Authlib reads `/.well-known/openid-configuration` itself and
finds the remaining endpoints.
The code takes its values from the environment, so set them in the shell you will start the
application from. The first three are what step 2 gave you; the fourth is yours:
```bash theme={null}
export VERIQA_ISSUER="https://veriqa.your-domain.com" # the issuer address
export VERIQA_CLIENT_ID="my-app" # the client you registered
export VERIQA_CLIENT_SECRET="…" # confidential client only
export FLASK_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_hex())')"
```
`FLASK_SECRET_KEY` is not issued by Veriqa — it is a random string of your own, the key Flask signs
the session cookie with. Keep it out of version control and use a separate value in every
environment. The public variant needs no `VERIQA_CLIENT_SECRET`: a public client has no secret at
all. If a variable your variant reads is missing, the very first run fails with
`KeyError` while `app.py` is still being imported, before any route is served.
Each variant below is the complete top of `app.py`: the Flask application, the session secret Authlib
needs, and the provider registration. The routes of [step 4](#4-define-the-sign-in-routes) go into
the same file, below it.
```python app.py — public client (PKCE) theme={null}
import os
from authlib.integrations.flask_client import OAuth, OAuthError
from flask import Flask, redirect, session, url_for
app = Flask(__name__)
# Authlib keeps the OAuth state and the PKCE code_verifier in the Flask session,
# so a secret key is required.
app.secret_key = os.environ["FLASK_SECRET_KEY"]
oauth = OAuth(app)
oauth.register(
name="veriqa",
client_id=os.environ["VERIQA_CLIENT_ID"],
server_metadata_url=f"{os.environ['VERIQA_ISSUER']}/.well-known/openid-configuration",
client_kwargs={
"scope": "openid profile channel",
"code_challenge_method": "S256", # enables PKCE — must be set explicitly
},
)
```
```python app.py — confidential client theme={null}
import os
from authlib.integrations.flask_client import OAuth, OAuthError
from flask import Flask, redirect, session, url_for
app = Flask(__name__)
# Authlib keeps the OAuth state in the Flask session, so a secret key is required.
app.secret_key = os.environ["FLASK_SECRET_KEY"]
oauth = OAuth(app)
oauth.register(
name="veriqa",
client_id=os.environ["VERIQA_CLIENT_ID"],
client_secret=os.environ["VERIQA_CLIENT_SECRET"],
server_metadata_url=f"{os.environ['VERIQA_ISSUER']}/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile channel"},
)
```
**PKCE in Authlib does not turn itself on — unlike the authentication method.** The client
authentication method is derived automatically: with a `client_secret` it is `client_secret_basic`,
without one it is `none`, so a public client is simply the absence of a secret. PKCE, however, is an
independent switch: with no `code_challenge_method` in `client_kwargs` a public client will run
**without** PKCE. Set it explicitly.
Switching between deployments — cloud, Docker, OS service — comes down to changing
`VERIQA_ISSUER` alone.
## 4. Define the sign-in routes
```python app.py theme={null}
@app.route("/")
def index():
user = session.get("user")
if user is None:
return redirect(url_for("login"))
return user
@app.route("/login")
def login():
redirect_uri = url_for("authorize", _external=True)
return oauth.veriqa.authorize_redirect(redirect_uri)
@app.route("/authorize")
def authorize():
try:
token = oauth.veriqa.authorize_access_token()
except OAuthError as error:
# The user did not go through with the sign-in: the answer carries error=access_denied
# instead of a code. That is an answer, not a fault.
if error.error == "access_denied":
return redirect(url_for("signin_cancelled"))
raise
userinfo = token["userinfo"]
session["user"] = {
"sub": userinfo["sub"],
"name": userinfo.get("name"),
"channel_type": userinfo.get("channel_type"),
}
return redirect("/")
# Where the callback sends a user who did not go through with the sign-in. A route of its own
# because "/" sends an anonymous visitor to /login — straight back to the page they just left.
@app.route("/signin-cancelled")
def signin_cancelled():
return "Sign-in was cancelled. Open / to sign in again."
```
`authorize_access_token()` exchanges the code for tokens and parses the `id_token` itself — the parsed
claims are in `token["userinfo"]`, with no separate call to the userinfo endpoint. The PKCE
`code_verifier` is generated and stored by the library, so you never pass it by hand.
The `except` branch handles a sign-in that did not complete. The sign-in page has a
[**Cancel sign-in** button](/docs/guides/customization#cancel-button), on by default; a user who clicks
it comes back to `/authorize` with `error=access_denied` and no code. `authorize_access_token()`
answers an error response by raising `OAuthError` — without the branch the return ends in an
HTTP 500 instead of a page of your application.
Between `/login` and `/authorize` Veriqa does its work: the user sees the sign-in page with a QR code
and the channel list and confirms the sign-in in a messenger on their phone.
`/` is where the callback finally lands the browser: with nothing in the session yet it sends you to
`/login`, and after a completed sign-in it shows what was stored — a dict returned from a Flask view
is serialized to JSON, so the outcome is visible without a template.
## 5. Read the claims
Veriqa adds the base set of claims to the tokens itself:
| Claim | Meaning | Where |
| - | - | - |
| `sub` | Stable subject identifier | both tokens |
| `auth_time` | When the confirmation completed (Unix) | `id_token` only |
| `amr` | The channel type that confirmed the sign-in (`telegram`, `whatsapp`, `max`, `email`) | both tokens; an array of strings in the `id_token` (`["telegram"]`), even for a single method |
| `name`, `email`, `phone_number`, … | From the resolved identity — depending on the requested scopes | both tokens |
| `picture` | The user's avatar — only with the `avatar` scope, also listed in `AllowedScopes` ([details](/docs/concepts/oidc-explainer#resolved-identity-and-claims)) | `access_token` only — read it from userinfo |
**Channel claims are issued only with the `channel` scope:**
| Claim | Meaning |
| - | - |
| `channel_type` | The channel type the user is linked through |
| `channel_user_id` | The user identifier inside that channel |
Without the requested `channel` scope these two claims end up in **no** token — and therefore do not
appear in the userinfo response either, which mirrors the access token. An application uses exactly
these claims to tell linked channels apart, so the scope is mandatory for account linking.
```python theme={null}
channel_type = token["userinfo"].get("channel_type") # None if the channel scope was not requested
```
## 6. Restrict sign-in to a specific channel
The standard OIDC `acr_values` parameter in the `channel:{type}` form narrows the choice on the
sign-in page. Extra keyword arguments to `authorize_redirect` go into the authorize request as-is:
```python theme={null}
return oauth.veriqa.authorize_redirect(
redirect_uri,
acr_values="channel:telegram",
)
```
Several space-separated values (`channel:telegram channel:whatsapp`) let the user pick from the listed
ones. Without the parameter every channel enabled on the server is available.
## 7. Checkpoint
Start the application from the directory holding `app.py`, in the shell where the variables of
[step 3](#3-register-the-provider) are exported:
```bash theme={null}
flask --app app run
```
Flask serves it on `http://127.0.0.1:5000` — that is the address the `redirect_uri` registered in
[step 2](#2-register-a-client-on-the-veriqa-side) is built from.
`curl $VERIQA_ISSUER/.well-known/openid-configuration` returns metadata with `https` URLs.
Open `http://127.0.0.1:5000/login` — the browser should land on the Veriqa page with the QR code
and the channel list.
Confirm the request in a trusted channel; the browser returns to your `/authorize` route, which
redirects it to `/`.
`/` shows the session as JSON: `sub` is filled in, and `channel_type` carries the channel that
confirmed the sign-in — it stays `null` if the `channel` scope was not requested.
## Next steps
Channel linking, re-confirmation and action approval — at the HTTP level.
Deploy your own issuer in Docker.
The same issuer without Docker — systemd or a Windows service, from an archive.
Where Veriqa fits into the standard flow.
What to check before shipping.
# Quickstart self-hosted
Source: https://veriqa.app/docs/quickstart/self-hosted
Run Veriqa as a standalone OIDC auth server in Docker — your application stays a plain OIDC client.
In self-hosted mode Veriqa runs as its **own process**: a standalone OIDC auth server in a
container (OpenIddict inside, though your application never needs to know that). Your application
talks to it over standard OpenID Connect and does not reference a single Veriqa package.
**Compliance-friendly path.** Because your application is a plain OIDC client, no Veriqa
component enters your build or your SBOM. If your organization runs license scanners over
dependencies, this is the mode to pick — you integrate over a protocol, not over a package.
The embedded alternative is the [.NET quickstart](/docs/quickstart/dotnet).
## 1. Get the image
Every release is published to the container registry of the public repository:
```bash theme={null}
docker pull registry.gitlab.com/veriqa/veriqa
```
Without a tag Docker takes `latest` — the newest release. To pin a release in production, add its
version as the tag (`registry.gitlab.com/veriqa/veriqa:`).
The published image is `linux/amd64`. On another architecture, build it from the repository root
— `build/docker/Dockerfile` publishes the same standalone host — and use your local tag in place of
the image name in the commands below:
```bash theme={null}
docker build -f build/docker/Dockerfile -t veriqa-authserver .
```
The image is multi-stage (SDK build → ASP.NET runtime), runs as a **non-root** user (UID `1654`),
exposes port **8080** over HTTP and ships a `HEALTHCHECK` against `/health/live`. TLS is expected to
be terminated in front of it — see [step 6](#6-put-it-behind-a-reverse-proxy).
## 2. Configure it
The container reads the same configuration as any ASP.NET Core app, so every setting can be
supplied as an environment variable. Nested keys use a **double underscore** as the separator:
`Veriqa:OpenIddict:Database:Provider` becomes `Veriqa__OpenIddict__Database__Provider`.
```bash docker run theme={null}
docker run -d --name veriqa \
-p 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
-e Veriqa__OpenIddict__Database__Provider=PostgreSQL \
-e Veriqa__OpenIddict__Database__ConnectionString="Host=postgres;Database=veriqa;Username=veriqa;Password=…" \
-e Veriqa__TransactionEngine__Store__Provider=PostgreSQL \
-e Veriqa__TransactionEngine__Store__ConnectionString="Host=postgres;Database=veriqa;Username=veriqa;Password=…" \
-e Veriqa__Channels__Telegram__Enabled=true \
-e Veriqa__Channels__Telegram__BotToken="YOUR_BOT_TOKEN" \
-e Veriqa__Channels__Telegram__WebhookBaseUrl="https://auth.your-domain.com" \
-e Veriqa__Channels__Telegram__WebhookSecretToken="YOUR_SECRET" \
-e Veriqa__Channels__Telegram__InboundVerification=shared_secret \
registry.gitlab.com/veriqa/veriqa
```
Secrets on the command line land in your shell history and in `docker inspect`. Use an
`--env-file`, Docker/Kubernetes secrets or your secret manager instead — the sample above is
spelled out only to show the key names.
The stores are configured independently and may point at the same database:
* **`Veriqa:OpenIddict:Database`** — OIDC clients, authorizations and tokens. **There is no
default:** a container with an empty section fails to start — the startup check refuses and names
the fix (in `Development` the same text is logged as a `Warning`). Set `Provider` to `PostgreSQL`
or `SqlServer` and supply `ConnectionString` for anything real; `Provider` = `InMemory` is the
explicit choice of the volatile store: every restart drops issued tokens — fine for development and
for a [single-instance connector](/docs/guides/storage#single-instance-connector-without-a-database).
* **`Veriqa:TransactionEngine:Store`** — the sign-in transactions themselves. Setting
`ConnectionString` switches it to a durable store; leaving it empty keeps the in-memory store,
whatever `Provider` says. `Provider` is `PostgreSQL` (also what an empty key means) or `SqlServer`
for EF Core, or `Redis` — then `ConnectionString` is a Redis connection string and `KeyPrefix`
(default `veriqa:tx:`) prefixes the keys. Redis keeps no
[collected claims store](/docs/guides/storage#collected-claims-store).
* **`Veriqa:Logging:Store`** — the audit trail, optional: by default it shares the transaction
database. A transaction store on `Redis` has no database to share, so the trail then needs a
connection string of its own. See [Audit sink](#audit-sink).
Provider values are case-insensitive. For SQL Server the connection string takes the usual SqlClient
form: `Server=sqlserver;Database=veriqa;User Id=veriqa;Password=…`.
The resolvers are **fail-fast**: an unrecognized provider value, or a relational provider without
a connection string, stops the container at startup instead of silently falling back.
### Audit sink
Audit records are written only when `Veriqa:Logging:Mode` is `Audit`. Two optional keys decide
where they go, and each falls back on its own:
| Key | When unset |
| - | - |
| `Veriqa:Logging:Store:ConnectionString` | `Veriqa:TransactionEngine:Store:ConnectionString` is used |
| `Veriqa:Logging:Store:Provider` | `Veriqa:TransactionEngine:Store:Provider` is used; empty there too — `PostgreSQL` |
**Except on Redis.** With `Veriqa:TransactionEngine:Store:Provider` = `Redis` the sink borrows
nothing: a Redis connection string is no database for the audit trail. Set
`Veriqa:Logging:Store:ConnectionString` and the sink runs on its own `Provider`, or on `PostgreSQL`
when that is unset; leave it unset and the trail stays in memory.
Leave both unset and the trail lives in the transaction database. Give it a database of its own when:
* the auditor should own the account the records are kept under, separate from the one the sign-in
path writes with;
* the load and backup profiles differ: transactions are gone within minutes, audit records stay for
`RetentionDays`;
* transactions stay in memory while the trail must survive a restart — set only
`Veriqa:Logging:Store:ConnectionString`.
A provider of its own on top of a borrowed connection string is refused at startup, with a message
naming both `Provider` keys: set the audit connection string as well, or drop the provider. When the
audit connection string differs from the transaction one, readiness gets a check of its own
(`postgresql-audit` or `sqlserver-audit`).
**An existing deployment.** An installation that ran without these keys keeps its `AuditRecords` in
the transaction database. Setting `Veriqa:Logging:Store:ConnectionString` does not move them: the new
database starts with an empty table, and the rows left behind are no longer swept by retention. Copy
them over, or delete them, as part of the switch.
**`Audit` mode needs a durable sink.** With `Veriqa:Logging:Mode` = `Audit` and neither connection
string set, records would be kept in memory and lost on restart — so outside `Development` the
container refuses to start:
```
Veriqa:Logging:Mode is 'Audit', but the audit store is in memory: set 'Veriqa:Logging:Store:ConnectionString' or 'Veriqa:TransactionEngine:Store:ConnectionString'.
```
With `Veriqa:TransactionEngine:Store:Provider` = `Redis`, with or without its connection string, the text
names only `'Veriqa:Logging:Store:ConnectionString'`:
that is the one key that fixes it.
In `Development` the same text is logged as a `Warning`. The check runs at startup: a switch to
`Audit` made later without a restart is not checked again.
## 3. Provide the token certificates
Outside `Development` the server refuses to start without **both** an OpenIddict signing and an
encryption certificate — there are no auto-generated development keys to fall back on. Supply each
one either as a file path or inline as Base64 PFX.
Any RSA-2048 (or stronger) certificate will do, and a self-signed one is enough. One way to issue both
with OpenSSL:
```bash theme={null}
for name in signing encryption; do
openssl req -x509 -newkey rsa:2048 -nodes -days 730 -subj "/CN=veriqa-$name" \
-keyout "$name.key" -out "$name.crt"
openssl pkcs12 -export -inkey "$name.key" -in "$name.crt" -out "$name.pfx" \
-passout env:PFX_PASSWORD
done
```
The `PFX_PASSWORD` variable holds the password you then pass as `…CertificatePassword`. Keep the
`.key` files out of the image and the repository. `openssl` creates both `.pfx` files as
`-rw-------`, owned by the user that ran it — which matters as soon as you mount them, see the
warning below.
```bash theme={null}
-e Veriqa__OpenIddict__Server__SigningCertificateBase64="$(base64 -w0 signing.pfx)" \
-e Veriqa__OpenIddict__Server__SigningCertificatePassword="…" \
-e Veriqa__OpenIddict__Server__EncryptionCertificateBase64="$(base64 -w0 encryption.pfx)" \
-e Veriqa__OpenIddict__Server__EncryptionCertificatePassword="…"
```
The `…Path` variants (`SigningCertificatePath` / `EncryptionCertificatePath`) take a path to a PFX
mounted into the container — usually the better option, since the Base64 form makes for very long
environment values.
**A mounted PFX has to be readable by the container user.** The image runs as UID `1654`
([step 1](#1-get-the-image)), while the files the recipe above produces are readable only by the
host user that issued them. Mounted as they are, the container exits at startup with
`CryptographicException: Error occurred during a cryptographic operation` — a message that names
neither the path nor the access denial. Before mounting, hand the files to the image user:
```bash theme={null}
sudo chown 1654 signing.pfx encryption.pfx
```
`chown` is the better of the two ways: the files stay closed to everyone but the image user. It
does need root on the host — where you do not have it, `chmod 644 signing.pfx encryption.pfx`
also lets the container read them, but it makes the PFX readable by **every** local user, while
the password that unlocks it sits in the container environment next to it. Use `chmod` only on a
host that is yours alone. The `.key` files are not mounted at all, and the Base64 form is
unaffected — there the certificate travels in an environment value, not as a file.
These certificates are what your issued tokens are signed and encrypted with. Keep them stable
across restarts and shared across instances — rotating them invalidates every live token — and
treat the passwords as secrets.
## 4. Declare your client
The application that signs users in is registered as an OIDC client under
`Veriqa:OpenIddict:Clients`. The image ships **no** clients, so this step is mandatory — and every
client is validated at startup: a missing `ClientId`, an empty `AllowedRedirectUris` or empty
`AllowedScopes` stops the container with a message naming the offending entry.
Alongside an env-file this is easier to express as JSON: mount a `veriqa.json` settings file into the
container at `/app/config/veriqa.json` (the variable `VERIQA_SETTINGS_FILE` moves it elsewhere; if no file exists at the path it names, the container does not start). The
file may carry any Veriqa setting, not only clients; a key set through the environment or `--env-file`
wins over the same key in the file:
```json veriqa.json theme={null}
{
"Veriqa": {
"OpenIddict": {
"Clients": [
{
"ClientId": "my-app",
"DisplayName": "My Application",
"AllowedRedirectUris": [ "https://app.your-domain.com/signin-oidc" ],
"AllowedScopes": [ "openid", "profile", "offline_access" ],
"AllowRefreshTokens": true
}
]
}
}
}
```
```bash theme={null}
docker run -d --name veriqa -p 8080:8080 \
-v "$(pwd)/veriqa.json:/app/config/veriqa.json:ro" \
--env-file veriqa.env \
registry.gitlab.com/veriqa/veriqa
```
`redirect_uri` is matched **exactly** against `AllowedRedirectUris` — wildcards are not supported,
URIs must be absolute, and `http` is accepted only for loopback addresses (`localhost`,
`127.0.0.1`, `[::1]`) in development.
The entry above has no `ClientSecret`, which makes `my-app` a **public client**: it is identified at
`/connect/token` by PKCE (`S256`) alone, and Veriqa requires PKCE from it. Add a `ClientSecret` and
the client becomes confidential — `/connect/token` then expects the secret, and PKCE is optional.
The entry and the OIDC library of your application have to agree on which of the two it is: a
library configured with a secret against an entry without one is refused, and so is the reverse. A
secret is mandatory only for a client with `AllowClientCredentials` or `AllowIntrospection`.
The scopes decide what your application may receive, not what the channel has: Telegram, for one,
never hands over an email and gives the phone number only when asked. Veriqa can ask for a missing
phone number or email inside the same sign-in — add the `phone` / `email` scope and raise the
claim's level: [Phone number](/docs/guides/phone-number),
[Progressive claim enrichment](/docs/concepts/claim-enrichment).
## 5. Point your application at it
Your app is a standard OIDC client. Nothing Veriqa-specific goes into it — this is the same
configuration you would write for any OpenID Connect provider. In ASP.NET Core it comes from one
package:
```bash theme={null}
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
```
```csharp Program.cs (your application, not Veriqa) theme={null}
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://auth.your-domain.com";
options.ClientId = "my-app";
options.ResponseType = "code";
options.Scope.Add("profile");
options.CallbackPath = "/signin-oidc";
// A user who presses "Cancel sign-in" on the Veriqa page
// comes back with error=access_denied. That is an answer, not a fault: with the path set
// the handler redirects to that route of your application. With no path set the refusal
// would go to OnRemoteFailure below, like any other failed return.
options.AccessDeniedPath = "/signin-cancelled";
// Every other failed return — Veriqa answering error=server_error, say, because the channel
// did not deliver the confirmation — raises OnRemoteFailure; with no handler for it the
// return ends in an HTTP 500. The user is taken to a page of your application that offers
// to start over. The error text is deliberately not shown to the user: it is written to
// the log instead.
options.Events.OnRemoteFailure = context =>
{
context.HttpContext.RequestServices.GetRequiredService>()
.LogWarning(context.Failure, "The sign-in return carried an error.");
context.HandleResponse();
context.Response.Redirect("/signin-failed");
return Task.CompletedTask;
};
// Veriqa issues the user's name as the short OIDC `name` claim, while User.Identity.Name
// looks up ClaimTypes.Name (the long WS-Federation URI) by default — without this line the
// name stays empty after a successful sign-in.
options.TokenValidationParameters.NameClaimType = "name";
});
```
The user's avatar (`picture`) is gated by the Veriqa-specific `avatar` scope: add
`options.Scope.Add("avatar")` and `options.GetClaimsFromUserInfoEndpoint = true`, and list `avatar` in
the client's `AllowedScopes` from [step 4](#4-declare-your-client). The avatar is never put into the
`id_token` — see [OIDC + Veriqa explainer](/docs/concepts/oidc-explainer#resolved-identity-and-claims).
Discovery, the authorization code flow and token issuance are ordinary OpenIddict behavior, so
any conformant OIDC client library works — .NET, Node, Go, Java, Python.
The block above is the registration only. To run the sign-in of [step 8](#8-verify) you also need the
pipeline (`app.UseAuthentication()`, `app.UseAuthorization()`), an endpoint that issues the
challenge and the two routes a failed sign-in ends on — `/signin-cancelled`, which
`AccessDeniedPath` points at, and `/signin-failed`, where `OnRemoteFailure` redirects. A complete
client program, all of them included, is in
[step 5 of the .NET quickstart](/docs/quickstart/dotnet#5-wire-up-the-client-application). That program
is written for the in-process quickstart, so two of its values are its own: `Authority` points at
`https://localhost:7300`, and it asks for the `email` scope, which the client declared in
[step 4](#4-declare-your-client) does not allow — either drop `options.Scope.Add("email")` or add
`email` to its `AllowedScopes`. The rest applies as written.
## 6. Put it behind a reverse proxy
The container speaks HTTP; TLS terminates at your proxy. For OpenIddict to build correct `https`
URLs, the proxy must forward `X-Forwarded-Proto` / `X-Forwarded-For`, and the host must be told
which proxies to trust — in the same `veriqa.json` from [step 4](#4-declare-your-client):
```json veriqa.json theme={null}
{
"ForwardedHeaders": {
"KnownProxies": [ "10.0.0.5" ],
"KnownIPNetworks": [ "10.0.0.0/8" ]
}
}
```
This is not optional in a container. By default only loopback is trusted, and a request arriving
through the container network comes from the bridge gateway — so an unconfigured host silently
ignores `X-Forwarded-Proto`, keeps treating the request as `http`, and even
`/.well-known/openid-configuration` answers `invalid_request` ("This server only accepts HTTPS
requests"). Once the proxy network is listed, discovery returns `https` URLs and sign-in works.
Leave the proxy room for request headers as well. Sign-in requests carry cookies: Veriqa's own and,
when your application shares the host name or a parent domain with it (all `localhost` ports count as
one host during development), the client's OIDC correlation, nonce and session cookies. NGINX caps a
single header at 8k by default and answers a longer one with `400 Request Header Or Cookie Too Large`
— raise the limit in the `server` block:
```nginx theme={null}
large_client_header_buffers 4 32k;
```
The proxy is also where per-IP rate limiting belongs. The core counts its limits per route, per
user and per client, never per address, so a limiter keyed by client IP (`limit_req` in NGINX, a
WAF rule, fail2ban) is part of this same step — see
[Production hardening](/docs/guides/hardening#rate-limiting-and-anti-abuse).
[Behind a reverse proxy](/docs/guides/reverse-proxy), a page of its own, covers which paths the proxy has
to pass, the WebSocket of the sign-in page and a ready NGINX configuration.
## 7. Enable channels
Channel setup — bot tokens, webhook registration, Meta Cloud API, SMTP — is identical in both
delivery modes and lives in [Channel setup](/docs/guides/channels). One detail is specific to
self-hosting: the channel webhooks must be reachable **from the internet** at
`https://auth.your-domain.com/api/channels/…`, so the reverse proxy has to expose those paths.
At least one channel is mandatory — without it there is nowhere to confirm a sign-in. The command in
[step 2](#2-configure-it) enables Telegram; here is what its keys mean, so you can reach a working
sign-in without leaving this page:
| Key | Required | Why |
| - | - | - |
| `Veriqa__Channels__Telegram__Enabled` | yes | Turns the adapter on. No code registration needed — the image ships every built-in channel, configuration decides which of them are live |
| `Veriqa__Channels__Telegram__BotToken` | yes | Token of a bot created through [@BotFather](https://t.me/BotFather) |
| `Veriqa__Channels__Telegram__UpdateMode` | no | `Webhook` (default) or `Polling` |
| `Veriqa__Channels__Telegram__WebhookBaseUrl` | in `Webhook` mode | Public HTTPS base URL of this host |
| `Veriqa__Channels__Telegram__WebhookSecretToken` | in `Webhook` mode | Any random string; every inbound update is authenticated against it |
| `Veriqa__Channels__Telegram__InboundVerification` | yes | How the authenticity of an incoming event of the channel is verified: `shared_secret` in `Webhook` mode, `outbound_fetch` in `Polling` mode. An enabled channel without this key keeps the host from starting; the dictionary and the values of the other channels are in [Channel setup](/docs/guides/channels) |
| `Veriqa__Channels__Telegram__BotUsername` | no | Saves a Bot API round trip: when set, the `t.me` deep link behind the QR code is built directly; when empty, the bot name is resolved through the API. Does not affect update delivery |
Veriqa registers the webhook in the Bot API **itself** on startup — you do not call `setWebhook`
manually. Check the registration against the Bot API:
```bash theme={null}
curl -s "https://api.telegram.org/botYOUR_BOT_TOKEN/getWebhookInfo"
```
Expect `url` to equal `https://auth.your-domain.com/api/channels/telegram/webhook` and
`last_error_message` to be absent. The container log also carries `Telegram webhook registered: …`, but only at the
`Information` level: the image in `Production` logs from `Warning`, so there the line is not written.
The section is validated at startup like every other one: an enabled adapter with no `BotToken`, no
`WebhookSecretToken`, or with a relative or non-HTTPS `WebhookBaseUrl` stops the container with a
message naming the key.
`UpdateMode=Polling` needs no public URL — handy to verify sign-in before the proxy is configured,
but not for a deployed instance. Only one process may poll a bot: the Bot API hands the update
queue to a single `getUpdates` caller, so a second instance on the same token receives nothing —
see [Running more than one instance](#running-more-than-one-instance).
## 8. Verify
**Readiness criterion: steps 1–4 green.** The process is up, dependencies answer, discovery returns
`https` URLs, the channel is registered — the instance is deployed. Step 5 checks your integration
(the client and the scenario), not the server itself.
`curl -f http://localhost:8080/health/live` — the process is up.
`curl -f http://localhost:8080/health/ready` — configured dependencies (database, Redis,
RabbitMQ) answer. This is the probe to wire into your orchestrator.
`curl https://auth.your-domain.com/.well-known/openid-configuration` returns the OIDC metadata
with `https` URLs. If they come out as `http`, revisit [step 6](#6-put-it-behind-a-reverse-proxy).
For `Webhook` mode: `getWebhookInfo` of the bot returns your `/api/channels/telegram/webhook` as
`url` with no `last_error_message` — see [step 7](#7-enable-channels). With an empty `url` there
is nothing to confirm sign-ins with. In `Polling` mode there is no such call: read
`/health/channels` instead, and mind what it does not answer (below).
Trigger sign-in from your application and confirm it in a trusted channel on your phone. With
Email — and with any channel when `Veriqa:LoginConfirmation:Mode` is `OnWebPage` — the sign-in
page then asks once more: confirm there too.
**What `/health/channels` answers — and what it does not.** The endpoint reports every enabled
channel separately, and the per-channel value is what you read: `healthy`, `unhealthy` or
`timeout`.
* **The HTTP status is always `200`.** A channel outage is reported as `Degraded`, never as
`Unhealthy`, by design: an unreachable messenger must not take a working replica out of
rotation. `curl -f` therefore succeeds even with every channel down — alert on the body, not on
the status code. For the same reason channels are absent from `/health/ready`.
* **The report may be stale.** It is a snapshot refreshed channel by channel — each channel at
most once in its own interval, by default once every 15 seconds
(`Veriqa:Channels:{Channel}:HealthProbeInterval`) — and only a read moves it: the read that finds
a channel's snapshot too old still answers from that old one and refreshes it in the background.
The first read after a channel goes down can still say `healthy`, the next one tells the truth —
so poll the endpoint on an interval if you alert on it.
* **It does not know who owns the update queue.** The probe is an authenticated call to the
platform, so a wrong or revoked token does come out as `unhealthy` — but a token that is valid
and already taken by another poller answers perfectly. See
[Running more than one instance](#running-more-than-one-instance).
## 9. Common first-run failures
Almost everything that breaks on a first run breaks **at startup** — the container does not stay
alive half-broken, it exits with a message naming the key. So the first move on any failure is
`docker logs veriqa`.
| Symptom | Cause | Where to go |
| - | - | - |
| Container exits: `Set Veriqa:OpenIddict:Server:SigningCertificateBase64 … The parameters are required in a non-Development environment.` — or the same about `EncryptionCertificateBase64` | Outside `Development` **both** certificates are required. The message names the one that was missing: with neither set, signing fails first; with only signing set, encryption does | [Step 3](#3-provide-the-token-certificates) |
| Container exits at startup: `CryptographicException: Error occurred during a cryptographic operation`, with `SigningCertificatePath` / `EncryptionCertificatePath` set | The mounted PFX is not readable by the container user (UID `1654`): `openssl` leaves the file readable only by its owner. The message names neither the path nor the access denial, so it reads like a password or format problem | [Step 3](#3-provide-the-token-certificates) — `sudo chown 1654` on the files before mounting them; `chmod 644` only where root is unavailable, and it makes the PFX readable by every local user |
| Container exits: `Clients[0] (my-app): AllowedRedirectUris cannot be empty.` (or `ClientId cannot be empty.`, `AllowedScopes cannot be empty.`, `invalid redirect URI '…'`) | An entry of `Veriqa:OpenIddict:Clients` is incomplete | [Step 4](#4-declare-your-client) |
| Container exits: `BotToken is required when the Telegram adapter is enabled.` or `WebhookBaseUrl must use HTTPS (a Telegram Bot API requirement).` | The channel is enabled but not fully configured | [Step 7](#7-enable-channels) |
| Container exits: `Unsupported value 'Veriqa:OpenIddict:Database:Provider' = 'Postgres'. Allowed: 'InMemory', 'PostgreSQL', 'SqlServer'.` | A typo in the provider value. The resolver is fail-fast: there is no silent fallback to `InMemory` | [Step 2](#2-configure-it) |
| Container exits: `Veriqa:Logging:Mode is 'Audit', but the audit store is in memory: set 'Veriqa:Logging:Store:ConnectionString' or 'Veriqa:TransactionEngine:Store:ConnectionString'.` | `Audit` mode is on, but neither the audit nor the transaction store has a connection string — outside `Development` the records would have nowhere durable to go. With `Veriqa:TransactionEngine:Store:Provider` = `Redis` the text names only the audit key: a Redis store lends the sink nothing | [Audit sink](#audit-sink) |
| Container exits: `With 'Veriqa:OpenIddict:Database:Provider' = 'PostgreSQL' you must set 'Veriqa:OpenIddict:Database:ConnectionString'.` | The relational provider is set, the connection string is not. The transaction store follows a different rule: there the **absence** of a connection string legitimately selects the in-memory store | [Step 2](#2-configure-it) |
| Container is alive, but `/.well-known/openid-configuration` answers `invalid_request` ("This server only accepts HTTPS requests") | The proxy is not listed as trusted, `X-Forwarded-Proto` is ignored | [Step 6](#6-put-it-behind-a-reverse-proxy) |
| Container is `healthy` and `/health/ready` answers 200, but `/connect/authorize` answers `503` with `"title": "channel_display_failed"` | The channel's token is wrong or revoked, and the channel runs in `Polling` mode: the key is set, so the channel passes startup validation, and the platform rejects the token only when the sign-in page is prepared. In `Webhook` mode the same token already fails the webhook registration at startup, and the container exits | `/health/channels` — the channel is reported as `unhealthy` (channels are deliberately kept out of readiness); the reason is in `docker logs veriqa` |
| Container exits at startup with `bad webhook: …` (for example `bad webhook: Failed to resolve host`), the token is correct | Telegram could not reach `WebhookBaseUrl`: the host name has not propagated to DNS yet, the certificate is not accepted, or the address is unreachable from the internet | Wait until the name resolves through an external resolver (`dig @8.8.8.8 auth.your-domain.com`) and start again, or begin with `UpdateMode=Polling` — [step 7](#7-enable-channels) |
| The sign-in page opens, but nothing arrives in the messenger (or the button does not respond) | The `/api/channels/telegram/webhook` path is not exposed by the proxy, or `WebhookBaseUrl` does not point at the public address | [Step 7](#7-enable-channels) |
| The same, in `Polling` mode — and `/health/channels` still reports the channel as `healthy` | Another process is already polling this bot, most often a staging installation left running on the production token. The Bot API gives the update queue to one `getUpdates` caller, and the loser receives nothing; its token stays valid, so the health probe sees no problem | `docker logs veriqa` — a repeating error of the polling loop carrying `Conflict: terminated by other getUpdates request` (the `409` of that refusal is not part of the logged text). One poller per bot: [Running more than one instance](#running-more-than-one-instance) |
## Running more than one instance
A single container needs nothing extra. Scaling out requires shared state:
| Concern | Setting | Why |
| - | - | - |
| SignalR backplane | `Veriqa:SignalR:RedisConnectionString` | Live sign-in status must reach the browser whichever instance it is connected to |
| DataProtection keys | `Veriqa:DataProtection:RedisConnectionString` or `:KeysDirectory` | Without shared keys, instances cannot read each other's cookies and tokens |
| Channel stores | `Veriqa:ChannelStores:Redis:ConnectionString` | Without it each instance keeps them in process: an Email magic link, a start code, a link handle or a shared phone number that lands on another instance is not found — see [Channel stores: multiple replicas](/docs/guides/storage#channel-stores-multiple-replicas) |
| Transaction store | `Veriqa:TransactionEngine:Store` on a relational database or `Redis` | An in-memory store is per instance: a sign-in started on one instance is unknown to the others |
Leave the first two unset and each instance keeps its own in-memory keys — fine for one container,
broken for several. A shared volume for `KeysDirectory` works as an alternative to Redis.
One more thing does not scale by itself: **update delivery in `Polling` mode**. The Bot API hands
the update queue to a single `getUpdates` caller, so a second instance on the same bot token
receives nothing at all — while reporting itself `healthy`, because its token is valid and the
platform answers it. Only the log gives it away: the polling loop repeats the `409` refusal of the
Bot API, written out as `Conflict: terminated by other getUpdates request` — the number itself is
not in the line. Scale out on `Webhook` mode, where every instance is fed by the proxy, or give
each installation its own bot — that last one applies to a staging installation standing next to
production just as much as to a second replica.
## Next steps
Telegram, WhatsApp, other channels and Email — end to end.
Every section, key and default in one place.
What to check before shipping.
Transaction store, retention and migrations.
The same server without Docker — systemd or a Windows service, from an archive.
The paths to pass, the WebSocket and an NGINX configuration.
Ask for what the channel did not give, inside the same sign-in.
# Samples
Source: https://veriqa.app/docs/samples
The runnable samples and copy-into-your-code clients in the repository: what each one shows, where it lives and how to start it.
The repository ([veriqa.app/source](https://veriqa.app/source)) carries a gallery of samples under
`samples/`. Everything there is **MIT-licensed** and written to be lifted into your own project.
**Veriqa lives on the issuer side.** An application that only signs users in stays a plain
OpenID Connect relying party — no Veriqa package, no SDK. Veriqa packages appear only where the
application *is* the issuer (embedded) or calls the confirmation API server-to-server.
Samples come in two forms:
* **Runnable** — projects with their own `README.md`, ports and configuration. Start a .NET sample
with `dotnet run --project `; the samples outside .NET that run next to Veriqa are started with
`docker compose up` in their folder.
* **Code to copy** — one file per stack with the complete client wiring. There is no project file
around it: the surrounding application is yours.
## Sign-in and confirmation
The issuer with Veriqa embedded, a stock OIDC client, the same sign-in under .NET Aspire and the
client code for Java, Node and Python.
A backend asks a person to confirm an action: a plain confirmation, step-up before a
destructive action and approval of an AI agent's tool call.
## Showcase samples
Showcases imitate a whole product, so the flows can be seen end to end the way a user encounters them.
Sign-in by QR code from the sofa, a date of birth and a favorite genre collected inside the
sign-in, and step-up when buying a title; written for TV browsers.
A public support page, a sign-in that asks for no contact, a phone or email confirmed later,
and step-up before a ticket is closed or deleted.
A Claude Code plugin: selected agent actions wait until a person approves them in the messenger.
## Channel and deployment samples
| Path | What it shows | Form |
| - | - | - |
| `dotnet/custom-channel` | A third-party channel plugged in through the public SPI — see [Custom channel adapter](/docs/guides/custom-channel-adapter) | runnable · `https://localhost:7320` |
| `python/custom-channel-telegram` | Telegram through an external adapter in Python, plugged in by configuration — see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) | runnable · `docker compose` |
| `node/custom-channel-whatsapp-twilio` | WhatsApp through Twilio, an external adapter in Node — see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) | runnable · `docker compose` |
| `node/custom-channel-whatsapp-baileys` | WhatsApp through Baileys, an external adapter in Node — a working adapter for debugging WhatsApp flows, for small teams and for personal use; **an unofficial WhatsApp Web client, the number may be banned**; see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) | runnable · `docker compose` |
| `docker-compose/` | Self-hosted auth server behind Nginx with PostgreSQL, Redis and RabbitMQ — demonstration only | deployment |
The custom-channel host and the Aspire sign-in sample share port 7320 — run them one at a time.
## Running the .NET samples
* .NET SDK 10.0+ and a trusted local HTTPS certificate (`dotnet dev-certs https --trust`).
* **Enable at least one channel and give it credentials.** Every sample ships with all channels
`"Enabled": false`; with none enabled the sign-in page returns `no_channels_available`. A bot
token is the quickest route — see [Channel setup](/docs/guides/channels) and the sample's own README.
* In pairs such as `login` + `login-client`, start the issuer first: the client fails discovery otherwise.
# Channel buttons on your own page
Source: https://veriqa.app/docs/cloud/channel-buttons
When your application renders the sign-in buttons: the bootstrap request, the channels_enabled and feature_flags fields, caching, and what happens when the cloud is unreachable.
You need this path only if you render the "Sign in with Telegram / WhatsApp / …" buttons
**yourself** instead of sending the user to the Veriqa sign-in page. No buttons of your own means
no need for this page — [the ordinary connection](/docs/cloud/connect) is enough.
## Why `bootstrap` exists
The set of enabled channels is decided by the project owner in the Console and can change at any
time. A list hardcoded in your code turns into a dead button: the channel is switched off, the
user taps it, and nothing works until your next deploy.
`bootstrap` is a single GET at application start that fetches the **current** project
configuration instead of keeping it in your own config. It is a Veriqa extension, not a standard
OIDC endpoint.
```http theme={null}
GET /p/{project}/v1/bootstrap?client_id={client_id}
X-Veriqa-Cloud-Key: {cloud_key} // optional
Accept: application/json
```
Without the cloud key the response carries public fields only. The response is the **effective**
configuration for that specific `client_id` — the project's values narrowed by the application —
not a dump of project settings.
## What comes back
```json theme={null}
{
"issuer": "https:///p/{project}",
"authorization_endpoint": "…/connect/authorize",
"token_endpoint": "…/connect/token",
"userinfo_endpoint": "…/connect/userinfo",
"jwks_uri": "…/.well-known/jwks.json",
"channels_enabled": ["telegram", "whatsapp", "max", "email"],
"feature_flags": { "step_up_enabled": true, "channel_linking_enabled": true },
"ttl_seconds": 300
}
```
Two of these fields do the work; the rest are conveniences:
* **`channels_enabled`** — the reason the mechanism exists: exactly the set of buttons to show
right now;
* **`feature_flags`** — what tells you whether to surface step-up, channel linking and operation
approval in your own interface.
The endpoint addresses duplicate discovery and save one request at startup. `ttl_seconds` is how
long the response may be cached, 300 seconds by default; after that, fetch it again.
The stable fields of the contract are `channels_enabled` and `feature_flags`. Build your logic
on them, and do not tie code you would hate to rewrite to the other fields.
Numeric limits, if the response carries them, are **informational**: not a commitment, and not a
replacement for the protection running on the cloud side.
There is no branding in the response. The project name, logo, color and theme are applied by
the **server** when it renders the sign-in page — the integrator does not need them, and they
are not part of the contract.
## Behavior on failure
| Situation | What happens |
| - | - |
| cloud unreachable, no cache | immediate failure — the application should not run on guesses |
| cloud unreachable, cache valid | with cached fallback enabled: the old configuration, a warning and a background refresh; otherwise failure |
| `404` for the `client_id` | failure: the application is not in this project |
| `401` | failure: the cloud key is wrong or missing |
| the response does not match the contract | failure |
## What bootstrap does not guarantee
The `bootstrap` response is **not a promise** of what the user will see. The authoritative
resolution happens on the server at `/connect/authorize`: a `ui_config` or `acr_values` you pass,
along with per-user overrides, can produce a different result.
Treat bootstrap as the way to learn what is enabled in the project, not as a preview of the
sign-in window.
# Connecting to the cloud
Source: https://veriqa.app/docs/cloud/connect
The main path into Veriqa Cloud: five project addresses, an ordinary OIDC client, and no Veriqa libraries.
The main path is an ordinary redirect: the user goes to the Veriqa sign-in page and returns to
your application. From your code's point of view this is a **standard OIDC client** — the same one
you would set up for any provider.
## What you need
* **the project address**: `https:///p/{project}`;
* the application's **`client_id`** and, if it is confidential, its **`client_secret`**;
* a **`redirect_uri`** registered in the Console.
Veriqa libraries are **not required**. Neither is any preliminary request for configuration:
everything the client needs is in the discovery document.
`bootstrap` plays no part on this path. It is only needed when your own page renders the channel
buttons — see [channel buttons](/docs/cloud/channel-buttons).
## The five addresses that are enough
| Method | Path |
| - | - |
| GET | `/p/{project}/.well-known/openid-configuration` |
| GET | `/p/{project}/.well-known/jwks.json` |
| GET | `/p/{project}/connect/authorize` |
| POST | `/p/{project}/connect/token` |
| GET | `/p/{project}/connect/userinfo` |
Point your library at the **issuer** — it will take the rest from discovery. Spell the addresses
out by hand only where the client cannot do discovery.
## Authenticating requests
| What you call | How you authenticate |
| - | - |
| `token`, `introspection`, `revocation` | `client_id` + `client_secret` (Basic or `client_secret_post`); a public client uses PKCE without a secret |
| `status`, `health` | no authentication |
The cloud endpoints beyond OIDC use the `X-Veriqa-Cloud-Key` header, described on the channel
buttons page.
## Parameters `/connect/authorize` understands
Besides the standard `scope`, `state`, `nonce` and PKCE:
* **`acr_values=channel:`** — narrow the channels down to the ones listed (separate multiple
values with a space);
* **`ui_locales`** — the language of the sign-in window;
* **`ui_config`** — the code of a settings bundle created by the project owner; see the
customization section.
## Checking things on the spot
`GET /p/{project}/v1/status` returns the project and channel status, and `GET /v1/health` the
health of the cloud instance. Both are public, which makes them handy for monitoring and for
quickly answering "is it me or the cloud".
Addresses like `https://cloud.veriqa.io/...` in examples are illustrative. Your cloud's actual
host is shown in the Console.
# Veriqa Cloud
Source: https://veriqa.app/docs/cloud/overview
What the Veriqa cloud is, how it differs from running your own deployment, and which settings belong to the project owner rather than to your code.
Veriqa Cloud is the same messenger-based authentication, run and maintained by someone other than
you. Instead of your own server you get a **project** in the cloud: its own issuer, its own
applications, and its own set of enabled channels.
**Veriqa Cloud is currently unavailable.** This page describes the contract of its API.
## Concepts
| Term | What it is |
| - | - |
| **Project** | a cloud tenant; one project is one OIDC issuer |
| **Application** | an OIDC client inside the project: a `client_id` and, if confidential, a `client_secret` |
| **Cloud project key** | a project-level private key for the cloud endpoints; rotated without changing `client_id` or the secret |
| **Channel** | how the confirmation is delivered: `telegram`, `whatsapp`, `max`, `email` |
| **`ui_config`** | a named "branding plus flow behavior" bundle owned by the project and assigned to applications |
`{project}` in the addresses is either a slug or a UUID — both are accepted.
## Who configures what
The split is simple, and it is the main reason the cloud is less work than your own server:
* **the project owner**, in the Console: which channels are enabled, the sign-in window branding,
the `ui_config` sets, the confirmation and audit policies;
* **you, the integrator**, in your code: `issuer`, `client_id`, the secret, and handling the
return.
The channel set changes in the Console **without touching or redeploying your application**. That
is exactly why the list of enabled channels should not be hardcoded — see
[channel buttons on your own page](/docs/cloud/channel-buttons).
There is no public API for managing projects, applications or keys — all of that happens in the
Console.
## Two ways to connect
The user goes to the Veriqa sign-in page and comes back. All you need is an ordinary OIDC
client: `issuer` plus `client_id`. No Veriqa libraries involved.
Your page renders the "Sign in with Telegram / WhatsApp / …" buttons. That additionally needs a
`bootstrap` call to learn which channels are enabled right now.
Do not conflate them: they are different amounts of work. If you do not need your own buttons, the
whole `bootstrap` section is safe to skip.
## How the cloud differs from self-hosted
Beyond who runs it, the API surface differs too:
| Endpoint | Standard | Self-hosted |
| - | - | - |
| `/p/{project}/connect/revocation` | RFC 7009 | **present** at `/connect/revoke`, enabled by default |
| `/p/{project}/connect/introspection` | RFC 7662 | absent |
| `/p/{project}/connect/endsession` | OIDC Session Management | absent |
So there are two real differences — introspection and sign-out. When planning a move in either
direction, do not assume the two are equivalent here.
# Sign-in window settings (ui_config)
Source: https://veriqa.app/docs/cloud/ui-config
A named "branding plus flow behavior" bundle: who creates it, how an application selects it, and why the server decides the outcome rather than your request.
`ui_config` is a **named bundle of sign-in window settings** owned by the project. It holds the
branding and the flow behavior; an application does not describe those settings itself — it names
the code of the bundle it wants.
## Who does what
* **the project owner** creates the bundles and gives them codes, dynamically, in the Console;
* **the application** is assigned a default bundle and a list of allowed codes; a code outside
that list cannot be passed.
The application selects a bundle with an authorize parameter:
```
&ui_config=checkout-dark
```
The parameter is optional. If it is absent, the application's default bundle applies.
## What a bundle controls
* which **channels** are shown and how — all at once or one (`ChannelDisplayMode`);
* how several channels **share the window** — behind tabs or side by side (`channel_layout`);
* on which devices the **channel buttons** are shown (`channel_button`);
* whether the window offers the **"Cancel sign-in" button** (`cancel`);
* the **design template** of the window;
* the **post-confirmation behavior**: what happens to the window and whether the flow continues
automatically;
* the **confirmation text**;
* the **wording of individual page elements** — the `texts` member.
`texts` rewords built-in texts of the sign-in window and of the other pages Veriqa renders, for the
applications that select this bundle. An entry is keyed by the English text of the element; its value
is a replacement key that goes through the locale files, or ready-made texts per language that bypass them:
```json theme={null}
"texts": {
"Get a link": "Get the form link",
"Sign in via Email": { "en": "Confirm via Email", "ru": "Подтвердить через Email" }
}
```
An entry of the bundle outranks the application's and the project's entry for the same text; entries
for other texts set there stay in force. A text the bundle sets with its own field (the title, the
instruction) is not reworded by `texts`. Which texts are open, and what happens to an entry that does
not fit, is in [Rewording a page element for one application](/docs/guides/customization#rewording-a-page-element-for-one-application).
`channel_layout`, `channel_button` and `cancel` are the bundle's side of three settings of the
sign-in window:
```json theme={null}
"channel_layout": "Grid",
"channel_button": { "show_channel_button": "MobileOnly" },
"cancel": { "enabled": false }
```
* `channel_layout` — `Tabs` (the default) or `Grid`: every channel on screen at once, each with its
own QR code. See [Channel layout](/docs/guides/customization#channel-layout).
* `channel_button.show_channel_button` — `Always` (the default), `MobileOnly` or `Never`. A value
that, together with the QR code visibility, would leave a device with neither the QR code nor the
button is not applied: the buttons are then shown on every device. See
[Channel button visibility](/docs/guides/customization#channel-button-visibility).
* `cancel.enabled` — `true` or `false`. The button is on unless some level switches it off; pressing
it returns the user to the application with `error=access_denied`. See
[Cancel button](/docs/guides/customization#cancel-button).
A member the bundle leaves out sets nothing, and the value comes from the application, the project
or the core.
What `ui_config` does **not** control — a common mismatch of expectations: the **sign-in
confirmation mode** and the **audit mode**. Those are the deployment owner's policies and cannot
be overridden through a window settings bundle.
## Resolution order
The final value of any setting is assembled along a chain, from the most specific level to the
most general:
```
user → request → ui_config → application → project → core
```
The first level where the value is both set **and permitted** wins. A higher level can only
**narrow** what a lower one allowed: protective settings define a ceiling, not a suggestion.
This is the answer to "why am I not seeing what I configured": the value is most likely set at a
more specific level, or clipped by a ceiling at a more general one.
**The authoritative resolution happens on the server at `/connect/authorize`**, not in whatever
[bootstrap](/docs/cloud/channel-buttons) returned. Bootstrap shows what is enabled in the project;
the window's final appearance is decided at request time.
## What is missing
There is no machine-readable way to obtain the list of allowed `ui_config` codes: the bootstrap
response does not carry it, even though `/connect/authorize` validates the code against an
allowlist. Ask the project owner or look in the Console.
# Architecture
Source: https://veriqa.app/docs/concepts/architecture
The building blocks behind the Veriqa connector.
Veriqa is the bridge between three parties: your application, OpenIddict, and the user's
trusted mobile channel. It initiates a sign-in or confirmation transaction, links it to the
chosen channel, waits for the user to confirm, then completes authentication in OpenIddict.
```mermaid theme={null}
flowchart LR
App["Client app (web / desktop)"]
subgraph Veriqa["Veriqa (in your host)"]
direction TB
TE["Transaction engine"]
CA["Channel adapters"]
OI["OpenIddict integration"]
end
Channel["Trusted app Telegram / WhatsApp / other channels / Email"]
OIDC["OpenIddict"]
App <--> TE
TE --- CA
TE --- OI
CA <--> Channel
OI --> OIDC
OIDC -.->|auth result| App
```
## Building blocks
Every sign-in, registration or confirmation is a transaction with its own context,
correlation id, state, lifetime, chosen channel, external identity, local account link and
audit log.
Each messenger or mobile channel is a separate adapter / connector. Out of the box:
Telegram, WhatsApp, other channels and Email — designed to be extensible.
Completes primary sign-in, second factor, step-up confirmation and channel linking — without
replacing OpenIddict.
The application finishes the flow via callback, SignalR live updates or polling once the
transaction is confirmed.
Pairs the user's messenger identity with the local account, optionally linking a
notification channel in the same step.
Each transaction carries an audit log of state changes and confirmations.
## QR orchestration
Veriqa generates a short-lived entry point per transaction. The QR code does not carry the
authentication logic — it simply opens the right bridge-flow for the chosen channel and ties
the user to the current transaction.
From starting the transaction to issuing OpenIddict tokens — step by step.
# How the auth flow works
Source: https://veriqa.app/docs/concepts/auth-flow
The full OIDC + Veriqa flow: from starting the transaction to issuing OpenIddict tokens.
Veriqa runs **inside your ASP.NET Core host** and is the OpenIddict auth server. The sign-in flow
is always the same: start a transaction, confirm it in a trusted channel on the phone, complete
authentication in OpenIddict. To your application it is a plain OIDC Authorization Code Flow —
Veriqa simply replaces the sign-in form with a messenger confirmation.
```mermaid theme={null}
sequenceDiagram
autonumber
participant App as Client app (desktop)
participant V as Veriqa (in your host)
participant OI as OpenIddict
participant Ch as Trusted channel (phone)
App->>V: Redirect to /connect/authorize
V-->>App: Sign-in page + QR / channel buttons
Note over V,Ch: A transaction opens, bound to the chosen channel
Ch->>V: User opens channel, confirms sign-in
V->>V: Update transaction state
App->>V: Live status (SignalR) or polling /api/transaction/{id}/status
V->>OI: Complete authentication (ClaimsPrincipal)
OI-->>App: code → /connect/token → tokens
```
## Step by step
The app redirects the user to `/connect/authorize`. Veriqa opens a transaction with the
client context, a correlation id, a lifetime and the chosen channel, and renders the sign-in
page with a QR code and channel buttons.
The user opens the trusted channel on their phone (Telegram / WhatsApp / other channels /
Email) and confirms the sign-in. The channel adapter returns the resolved identity to the
transaction.
The desktop learns of completion via a live update (SignalR) or by polling the transaction
status, after which Veriqa builds a `ClaimsPrincipal` and hands it to OpenIddict.
OpenIddict issues an authorization code; the app exchanges it for tokens at `/connect/token`.
From there it is standard OIDC.
## Starting the sign-in again
A user may start a sign-in to the same application again — in a new tab or with a second scan — and
reach the channel with the same account. That closes the sign-ins of that account to that
application that were still waiting for an answer, including a sign-in waiting for missing claims.
Only the one that reached the channel last stays:
* the chat gets no message about the closed sign-ins — no receipt and no "time expired" notice. If the
user presses a button on a question left in the chat, they get the usual expired receipt;
* their open pages show the expired screen and stay where they are: your application receives no
`error` on `redirect_uri` for a sign-in the user has left;
* in events, the audit trail and the transaction status such a sign-in is a failed transaction with
the reason `superseded_by_new_sign_in`; in [metrics](/docs/guides/observability#metrics) its outcome is
`superseded`.
Sign-ins are matched by tenant, application, channel type and the user's identifier in that channel,
at the moment the newer one is first tied to the channel account — the start in a messenger, the
link in an email, an incoming email. A sign-in that has already finished and a
[server-to-server confirmation](/docs/reference/confirmation-api) are never closed this way.
## Veriqa vs OIDC terms
| Veriqa term | OIDC term |
| - | - |
| Transaction start | `authorize` request |
| Channel confirmation | user authentication |
| Completion (SignalR / polling) | `callback` → token issuance |
| Resolved identity | ID token claims |
## Further reading
Exactly where Veriqa slots into the standard OIDC flow — and what it doesn't do.
Wire the flow into your application.
# Progressive claim enrichment
Source: https://veriqa.app/docs/concepts/claim-enrichment
The application fills in the user's profile as it goes, receiving verified data at the moment of a confirmation instead of demanding a form up front.
A conventional registration asks for everything at once — name, email, phone — before the person
has decided whether they want the service at all. Half the fields get filled with noise, and none
of them were ever verified.
**Progressive claim enrichment** inverts the order: the user signs in with a single action, and the
missing data arrives later — at the moment it is actually needed, and **already verified** wherever
the channel can vouch for it.
## Two ways to get a missing claim
**Inside the sign-in.** The [`Veriqa:ScopesClaims`](/docs/reference/configuration#veriqascopesclaims)
catalog gives each claim a level: `Required` — the sign-in does not complete without it; `Optional`
— the user is asked and may skip it; `IfAvailable` — issued only when the channel or the collected
claims store already has it. An application raises a level for itself with `ClaimLevels` of its
client entry, and a request raises it with the [`claims` parameter](/docs/reference/oidc-capabilities).
What the channel did not give, Veriqa collects before it issues the tokens, in the same transaction:
the phone number by a request in the channel ([Phone number](/docs/guides/phone-number)), the email address
by an email that confirms it or by an email the user sends (`ClaimCompletionEmailMethods` of the
[client entry](/docs/reference/configuration#clients)), and a field of the catalog that has `Completion`
on a form of its own — a text, a date or a choice from a list (`Format: Choice`). The
[Nova Stream showcase sample](/docs/samples/showcase/streaming-tv) collects a date of birth and a favorite
genre this way.
**How the email is collected.** The user sees at most three options, in the order of the list:
* **Confirm via email** — the user types the address and presses one button. What arrives is
decided by the list, not by the user: a code to enter on the page (`Code`), a link to a
confirmation page (`MagicLink`), or one email with both (`claim-code-link-mail`) when the list
names both — whichever the user does first confirms the address. `email_verified` is `true`;
* **an email the user sends** (`Push`) — on the device that confirmed the sign-in; `email_verified` is
`true`;
* **without confirmation** (`Unverified`) — the address is only typed; `email_verified` is `false`.
A value typed into the other fields of the form survives every step of the email flow — requesting
the email, entering the code, switching the method. "Continue" is always there: pressed before the
address is confirmed, it saves the fields and the page says "Confirm the address to continue."
Pressed with a code or an address typed into the email block, it does what that input is for instead
— checks the code, sends the email, or saves the address without confirmation — and leaves the other
fields as typed. Another email cannot be requested sooner than
`Veriqa:Channels:Email:MailResendInterval` after the previous one — see
[Sending an email again](/docs/guides/channels#sending-an-email-again).
What the email block shows as the user proceeds:
* **After the email is sent**, the code field and **Send again**. "Send again" sends nothing by
itself: it brings back the address field, filled with the address of the last email, so the user
either sends the same email once more or corrects the address first.
* **A code whose attempts are spent** turns the block back to that address field while emails are
left within the limits; when none are left, the block says "The code has no attempts left." and
asks for nothing more. When the per-address limit is reached, the block says "Too many emails have
been sent to this address. Try again later or enter another address."
* **An address confirmed by the link in the email** — opened on a phone, say — reaches, on its own,
the form on the device the sign-in started on: the form continues as if "I have confirmed the
address" were pressed, keeping what the user typed into the other fields. A page that fell back to
polling keeps the button for that.
* **A recorded address** stays in the block while other fields of the form are still open: "The
address … is confirmed", or "The address … is saved" for an address taken without confirmation.
The page the link in the email opens offers **Cancel** beside "Confirm the address", and **Close**
once the address is confirmed; both only close the tab.
**By a new authorize request.** When the need comes later, the application starts one more sign-in
with the scopes it now needs:
1. **First sign-in** — the minimum: `sub` plus whatever the channel gives under `scope=profile`.
The account exists and the service is usable.
2. **A contact is needed** — before the action that needs it, the application starts a new
authorize request with `scope=email` or `phone`. The user confirms in the messenger and the
claim arrives in the `id_token`.
3. **A notification channel is needed** — the same move with `scope=channel`: see
[channel linking](/docs/scenarios/channel-linking).
The Helio [showcase sample](/docs/samples#showcase-samples) confirms a phone number and an email this
way, each by a request of its own.
Either way there is no separate enrichment transaction and no separate API: every step is an
ordinary sign-in with the scopes and levels you need.
## Why the data is verified
The difference from a form is **who the source of the value is**. An address typed into a form is a
claim the user makes; an address that arrives as a claim from the Email channel follows from the
person having confirmed a sign-in from that very mailbox. There is nothing left to verify with a
separate email. The same holds for what Veriqa collects: a number shared in the channel and an
address confirmed by a code come with `phone_number_verified` / `email_verified`, while a form field
is what the user typed.
Which claims the **channel** gives depends on the channel, not on your request: Telegram does not
provide an email and never will, however many scopes you ask for. What the channel does not give,
Veriqa can collect inside the sign-in — but only `phone_number`, `email` and the catalog claims
with `Completion`; any other claim can only be `IfAvailable`. The catalog lists what each channel
gives: [Supported channels](/docs/guides/supported-channels).
## The right moment to enrich
The easiest place to fill in a profile is where the user is confirming something anyway: before a
payment, a change of payment details, granting access. The confirmation and the data collection
merge into one action instead of two — see [step-up](/docs/scenarios/step-up).
## Remembering what was collected
By default Veriqa keeps nothing after the sign-in: every sign-in starts from what the channel gives,
and what Veriqa collected lives only as long as the transaction. **An `Optional` claim is then asked
for at every sign-in** — the user who skipped it yesterday sees the question again today.
The [collected claims store](/docs/guides/storage#collected-claims-store) is an option that changes this.
With it Veriqa remembers by `sub` what it collected itself, and which `Optional` claims the user
declined, and asks only for what is still missing. A declined `Optional` claim is not asked for again
until its level is raised to `Required`. A stored value is checked against the current rules on
every sign-in: a value that no longer passes a tightened field rule, or an address obtained in a
way the current application does not allow, is asked for again.
What to know before turning it on:
* **Records follow the `sub`, and one person has a different `sub` in every channel.** A user who
signs in through Telegram and then through WhatsApp is asked again in the second channel: Veriqa
does not link accounts across channels.
* **It is a record of what was collected, not a copy of your profile.** Veriqa does not edit it and
does not synchronize it with your application. A token with a stored value in it also has
`updated_at` — the latest time Veriqa collected a value the token carries, in seconds since the
epoch. If the user has changed the data on your side since then, keep yours: do not overwrite
your profile from the token at every sign-in, compare `updated_at` with the time of your own
change.
* **It is shared by all applications of a tenant**, and so is a deletion.
Deleting the records of a user — at your request or after a period without sign-ins — is described
in [Storage](/docs/guides/storage#deleting-by-sub).
## What stays on your side
* **deciding what data you need and when** — the levels in the catalog and in the request; Veriqa
has no profile model beyond them;
* **your profile** — a claim that arrives in the token is your data from then on. The collected
claims store, when you turn it on, keeps only what Veriqa collected, to avoid asking again — it
is not a user directory;
* **the re-ask policy** — how long a received value stays valid in your application and when to ask
again is yours to define;
* **erasure** — when a user deletes their account, delete their records from the collected claims
store as well; Veriqa does not know about the deletion otherwise.
# How Veriqa differs
Source: https://veriqa.app/docs/concepts/comparison
A comparison with passkeys, built-in messenger sign-in widgets, SMS codes and device flow: where Veriqa is stronger, and where its limits are.
Veriqa complements existing solutions rather than replacing them. Here is where it is stronger —
and where its limits are.
Sign-in times and setup effort in figures, with the source of each one, are in
[Sign-in methods compared](/docs/concepts/sign-in-comparison).
One thing holds across the whole comparison: Veriqa's primary scenario is **cross-device**. The
application is open on a desktop, a TV or a kiosk, while the confirmation happens on a phone, in a
messenger the user already opens every day. Same-device sign-in is supported, but it is the
fallback here, not the main path.
## At a glance
| Alternative | Where Veriqa is stronger | When to choose the alternative |
| - | - | - |
| [Passkeys](#passkeys) | Nothing to set up in advance; works with Bluetooth off, in a VM or over remote desktop; also covers step-up, channel linking and action confirmations | You need phishing-resistant security |
| [Messenger sign-in widgets](#built-in-messenger-sign-in-widgets) | One integration for all channels and email; full OpenID Connect instead of a JS widget; the messenger need not be on the same device | — |
| [SMS codes](#sms-codes) | Faster, not billed per message, no phone number handed over, no code to retype | — |
| [Device flow](#device-flow-rfc-8628) | One tap in a messenger instead of a full sign-in on the second device | The second device has no messenger on it, and you need a strictly standard mechanism |
## Passkeys
The closest in user experience: the same "QR on screen, confirmation from the phone" flow.
**Passkeys are phishing-resistant by design — a higher security bar.** Where that bar is what
you need, passkeys are the right choice. Veriqa offers comparable convenience for simpler
scenarios.
Where Veriqa wins:
* **nothing has to be set up in advance** — no key is provisioned for the user; they confirm in a
messenger they already have;
* **it works where cross-device passkeys break**: Bluetooth off, a virtual machine, a remote
desktop, mismatched ecosystems between the computer and the phone;
* **it is not only sign-in**: second factor, channel linking, sensitive-action confirmation and an
audit trail come from the same mechanism.
## Built-in messenger sign-in widgets
Telegram and others have sign-in buttons of their own, and they are free. The difference is not
about price:
* **one integration layer for all channels and email** instead of a separate widget per
messenger;
* **full OpenID Connect instead of a JS widget**: tokens, sessions, account linking — your auth
system never has to learn what a messenger is;
* **the messenger need not be installed on the surface people sign in from**. A widget assumes the
app and the messenger share a device; with Veriqa they are deliberately different devices;
* beyond sign-in — second factor, channel linking and an audit trail.
## SMS codes
* confirmation in a messenger is **faster** and lives in an app the user opens every day;
* messages are **not billed per message** the way SMS is — at noticeable volumes that adds up to
direct savings;
* the identity is confirmed **without handing over a phone number** — less data that can leak;
* no retyping a code between screens: the confirmation is one tap in the messenger.
## Device flow (RFC 8628)
The standard answer to "how do we sign in on a device without a keyboard": the device shows a
code, the user types it on another device and confirms.
The difference is **what the user does on that second device**. In device flow they open a
browser, go to the provider's page, enter the code and authenticate — an ordinary sign-in with all
of its steps. With Veriqa they tap one message in a messenger where they are already signed in.
Device flow remains the right choice where the second device has nothing to do with a messenger
and a strictly standard mechanism is required. Veriqa wins where speed and avoiding a repeat sign-in
matter.
## Where Veriqa is not the answer
* **you need phishing-resistant-grade security** — use passkeys or hardware keys;
* **your audience has none of the supported messengers and no email** — the mechanism has nothing
to build on;
* **you need federated sign-out**: there is no end-session endpoint;
* **you need anti-fraud**: Veriqa reports the "sender is a bot" flag, but that is a cheap filter,
not a fraud-prevention system.
# How a sign-in completes
Source: https://veriqa.app/docs/concepts/completion
Three ways to learn that the confirmation happened: the redirect back, live status over SignalR and transaction status polling — plus how the Email channel differs.
The confirmation happens on the phone while the initiator waits on another device. Hence a question
an ordinary OIDC provider never raises: **how does the initiator learn that it happened?**
There are three ways, and they are not alternatives but different layers: the redirect back is
always mandatory, while live status and polling are about what to show the user while they wait.
## 1. The redirect to `redirect_uri` — the main path
The outcome arrives exactly as in any OIDC flow: the initiator's browser returns to `redirect_uri`
with an authorization code, and the application exchanges it for tokens. Nothing here is specific
to Veriqa.
For a desktop web application that is all you need: the sign-in page takes the user back on its
own.
The return query carries `iss` (RFC 9207). A strict relying party should compare it with its
expected issuer — cheap protection against a substituted provider.
## 2. Live status — SignalR
While the person picks up their phone, scans the code and taps the button, the initiator can show
the state: waiting for confirmation, confirmed, declined. The `/hubs/auth` hub exists for that —
the Veriqa sign-in page uses it itself, and it is available to your surface too.
This is what makes the wait legible on a TV or a kiosk, where nobody is watching the address bar.
## 3. Status polling — the fallback
Where WebSockets are unavailable (a strict proxy, a restricted network, an embedded device),
polling remains:
```http theme={null}
GET /api/transaction/{id}/status
```
This endpoint is about polling the state of the current sign-in. A confirmation your server
creates goes through a separate entry point: `POST /api/transaction/confirmation` creates a
confirmation transaction (the application authenticates with client credentials and receives a
`transaction_id` and an entry point for the user), and `GET /api/transaction/{id}/result` returns
its outcome (application token required). The full contract is in
[Server-to-server confirmation API](/docs/reference/confirmation-api).
Send `include_channel_entries: true` in the creation request and the response also carries
`channel_entries` — a deep link with its QR code for every available channel — next to the single
`channel_entry`, which stays as it is. Which channels are listed and in what order comes from the
channel display configuration (a `ui_config` record included), not from your request. A channel
with no address to open — Email in Pull mode, for one — is not listed, and when confirmation is
configured to happen on the web page the array is empty. Showing a single entry from the list is
your call. `response_valid_until` is then the earliest moment among all the entries returned.
Once the result is `confirmed`, a client the integrator has allowed to do so can also obtain the
confirming person's `id_token` — see
[linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in).
## The Email channel behaves differently
An important difference for integrators: **the web confirmation step depends on the channel**.
* on **Telegram** the `/connect/authorize/callback` return redirects immediately: the person has
already tapped "yes" in the messenger;
* on **Email** the same address renders a "Confirm your sign-in" page with a form — no code is
issued until it is submitted.
The reason: clicking a link in an email does not prove the same person is behind the
browser. But an integrator who tuned the flow on Telegram hits an "extra" screen on Email — that is
a property of the channel, not a malfunction.
The "Confirm your sign-in" page names the account the sign-in is performed with — the channel and an
identifier the person recognizes: the email address, the `@username`, or the phone number masked down
to its last four digits (`Telegram · +•••••••1234`). The internal channel user ID and the display name
are never shown. Whoever holds the page may not be the account's owner, so this line is what lets them
refuse a sign-in to someone else's account. Both answers bring the browser back to your
`redirect_uri`, even on another domain.
The link in the email of a **confirmation transaction** (an action your application asks the person to
confirm, not a sign-in) opens a page with a single "Continue" button. Opening the link changes nothing,
so a mail scanner that follows links confirms nothing. "Continue" leads to the page that shows what is
being confirmed, and the answer is accepted only in the browser that came through the link: a window
opened on another device shows a neutral page, and the person answers where they read the email.
## After the confirmation
What the window does on success (close, show a message, continue automatically) is a configuration
matter — part of the sign-in settings bundle the project owner or the integrator assigns to a
client. Receiving the tokens does not depend on it.
## Next
How a TV and a kiosk differ from a desktop.
What the callback failures mean.
# Hop mode
Source: https://veriqa.app/docs/concepts/hop-mode
A short one-time link in the QR code instead of the channel link: a light QR code, a scan signal on the computer, a launch page on the phone and the context of the scanning device. Why it is better and how to turn it on.
In hop mode the QR code on the sign-in page carries a **short one-time link to your Veriqa server**
instead of the messenger link. The phone opens it, the server records the scan and sends the user
on to the messenger or to email. That one extra hop gives the best user experience Veriqa offers.
**We recommend turning hop mode on.** It is off by default only because it needs an issuer set as
an absolute `https` address that the phone can reach. See [how to turn it on](#how-to-turn-it-on)
below.
## Key differences
| | Without hop mode | With hop mode |
| - | - | - |
| **What the QR code carries** | The channel link — a messenger deep link or an email. It is long, so the QR code is dense | A short link to your server. The QR code is light, any phone camera reads it at once, and its density does not depend on the channel |
| **What the computer shows after the scan** | Nothing until the channel answers the start or the confirmation arrives | Right away: "QR code scanned. Continue in Telegram." The channel choice gives way to the instruction "Continue on your phone…" with a "Choose another way" button |
| **One QR code for every channel** | No — each channel has its own QR code | Yes: `QrMode: Unified`, and the user picks the channel on the phone |
| **If the messenger app does not open** | Nothing to fall back on: the hop to the app is entirely up to the messenger | A launch page on the phone opens the app by itself and keeps a button to try again |
| **What is known about the scanning phone** | Nothing | Time, IP, browser, OS and device type — risk signals next to the [initiator context](/docs/concepts/initiator-context) |
| **The link reopened from another browser** | Not visible | Flagged — `WarnOnRepeatScan`, on by default |
| **What the deployment needs** | Nothing extra | An issuer that is an absolute `https` address the phone can reach |
## What the user sees
1. On a computer, a TV or a kiosk, the user scans the QR code with their phone camera.
2. The phone opens the short link. The sign-in page on the computer immediately says "QR code
scanned" and shows what to do next.
3. The phone moves on to the messenger — directly or through a launch page, depending on the
[redirect style](#redirect-style).
4. The user confirms with a single tap, and the sign-in completes on the computer.
If they scanned with the wrong device or changed their mind, the "Choose another way" button brings
the channel choice back on the same page, for the same sign-in. Once the channel has answered the
start, the button goes away: the sign-in is now bound to that channel.
## Redirect style
| `RedirectStyle` | What the phone gets |
| - | - |
| `Interstitial` (default) | A launch page: it opens the messenger app by itself and keeps a button in case the app did not open |
| `Direct` | A redirect straight to the messenger, with no page in between |
`Interstitial` is more reliable where a direct jump into the messenger app does not always work. The
Email channel has rules of its own — see [Email under hop mode](/docs/guides/channels#email-under-hop-mode).
## How to turn it on
```json appsettings.json theme={null}
{
"Veriqa": {
"OpenIddict": {
"Server": { "Issuer": "https://auth.example.com" }
},
"HopMode": { "Enabled": true }
}
}
```
Turn it on for the whole server, for a single channel (`Veriqa:Channels:{Channel}:HopEnabled`) or for
one application, in its OIDC client record. All the keys, and the order in which they override
each other, are in the [Veriqa:HopMode](/docs/reference/configuration#veriqahopmode) reference.
Every hop link is built on `Veriqa:OpenIddict:Server:Issuer`, so with hop mode on, the issuer must be
an absolute `https` address — otherwise the server does not start. To try hop mode on your own
machine, expose Veriqa through an HTTPS tunnel — see
[Testing the integration locally](/docs/integrations/local-testing#hop-mode-on-a-local-setup).
## Security
* **It is not a link shortener.** The server always derives where to send the user from the
transaction, never from the link, so a hop link cannot be turned into an open redirect.
* **The context of the scanning phone is a risk signal, not an authentication factor.** It helps
spot a request that is not the user's own, but the sign-in is still confirmed by the user in their
own messenger. The `HopMode.Collect*` keys decide what is collected: IP, browser and device type
are on by default; the browser fingerprint and geolocation only if you turn them on.
# Initiator context
Source: https://veriqa.app/docs/concepts/initiator-context
What the confirmation message shows: who initiated the sign-in and from where.
A Veriqa sign-in usually starts on the desktop and is confirmed in a trusted channel on the phone.
So the user can tell they are confirming **their own** sign-in (and not someone else's attempt),
Veriqa adds an **initiator context** to the confirmation message — a short summary of who started
the transaction and from where.
Initiator context is a hint for the user before confirming, not an access decision. It does not
replace security checks: the transaction is still bounded by TTL, one-time use and rate limiting.
## What is shown
The set of fields is defined by `DisplayFields`. Available fields:
| Field | Meaning |
| - | - |
| `application` | Name of the client application that started the sign-in (the OIDC client `DisplayName`) |
| `browser` | Initiator's browser (from the User-Agent) |
| `os` | Initiator's OS / platform (from the User-Agent) |
| `region` | Approximate region (city, country) by IP |
All four fields are shown by default.
## Where the data comes from
The context snapshot is built when the transaction starts (`/connect/authorize`) from the request:
* **IP address** — from the connection (best-effort; honors forwarded headers behind a trusted proxy).
* **Browser and OS** — by parsing the `User-Agent`.
* **Region** — by IP geolocation via an **offline** database (no calls to online services on the
trust path).
Geolocation requires two things the base packages do not carry: the satellite package
`Veriqa.Core.AuthServer.MaxMind` and a local GeoIP database (MaxMind `.mmdb` or compatible). With
the package missing or no database configured, the geo fields stay empty and the rest of the
context is shown as usual (graceful degradation). The standalone image already carries the
package; the database is always yours to mount. The raw `User-Agent` is not stored by default.
**Collection is on by default.** `Enabled`, `CollectIpAddress`, `CollectUserAgent` and
`CollectGeoLocation` all ship as `true`. What that means in practice — where each value is
stored, for how long, and how to switch the collection off — is in
[Security and data handling](/docs/guides/data-handling#collection-defaults).
## Configuration
The `Veriqa:InitiatorContext` section:
```json appsettings.json theme={null}
{
"Veriqa": {
"InitiatorContext": {
"Enabled": true,
"CollectIpAddress": true,
"CollectUserAgent": true,
"CollectGeoLocation": true,
"StoreRawUserAgent": false,
"DisplayFields": [ "application", "browser", "os", "region" ],
"GeoIp": {
"Provider": "OfflineDatabase",
"DatabasePath": "/data/geoip/GeoLite2-City.mmdb"
}
}
}
}
```
* `Enabled` (default `true`) — the global switch. When `false`, no snapshot is built and behavior
is unchanged.
* `CollectIpAddress` / `CollectUserAgent` / `CollectGeoLocation` — what to collect (all default `true`).
* `StoreRawUserAgent` — whether to keep the raw User-Agent (default `false`).
* `GeoIp.Provider` — `OfflineDatabase` (offline `.mmdb`); `GeoIp.DatabasePath` — path to the database.
The `GeoIp` keys switch nothing on until the application has a provider that reads the database.
It arrives as a satellite package — the core pulls no GeoIP library transitively:
```bash theme={null}
dotnet add package Veriqa.Core.AuthServer.MaxMind
```
```csharp theme={null}
builder.Services.AddMaxMindGeoIp();
```
Without it the host starts as usual and, with `CollectGeoLocation: true`, writes a startup warning
carrying the code `initiator_context_geoip_unavailable`.
**On the standalone image** the package is already included and switched on by
`GeoIp.DatabasePath`. The database is not part of the image — MaxMind's EULA covers the data, so you
download it yourself and mount it read-only:
```bash theme={null}
docker run -d --name veriqa \
-v /srv/geoip/GeoLite2-City.mmdb:/data/geoip/GeoLite2-City.mmdb:ro \
-e Veriqa__InitiatorContext__GeoIp__DatabasePath=/data/geoip/GeoLite2-City.mmdb \
registry.gitlab.com/veriqa/veriqa
```
A path to a missing or unreadable file does not stop the container: the provider logs
`GeoIP database is not open` and the geo fields stay empty.
## Per-client configuration
Display is configured **per OIDC client** through the client's fields in configuration. A value of
`null` means "inherit the global default". An application can only **narrow** the top-level field
set, never widen it.
```json appsettings.json theme={null}
{
"Veriqa": {
"OpenIddict": {
"Clients": [
{
"ClientId": "my-app",
"DisplayName": "My Application",
"InitiatorContextEnabled": true,
"InitiatorContextDisplayFields": [ "application", "region" ]
}
]
}
}
}
```
* `InitiatorContextEnabled` — whether to show the context in the confirmation for this client
(controls **display**; snapshot collection is governed globally).
* `InitiatorContextDisplayFields` — the narrowed field set for this client.
## Next steps
Where the initiator context is built in the flow.
The pre-production checklist.
# OIDC + Veriqa explainer
Source: https://veriqa.app/docs/concepts/oidc-explainer
What Veriqa does inside a standard OIDC flow, and what it deliberately does not.
This explainer is for developers who have worked with OIDC but not with Veriqa. It shows where
exactly Veriqa plugs into a standard flow — and where it stops.
## OIDC on one screen
OpenID Connect is a thin identity layer on top of OAuth 2.0. The application receives two
tokens: an `id_token` (who the user is) and an `access_token` (access to APIs). The app does
not check passwords itself — it trusts the **issuer** (authorization server), which returns
signed tokens.
## Authorization Code Flow + PKCE
The standard secure flow for web and desktop apps:
1. The app redirects the user to the issuer's `/authorize` (with `state`, `nonce` and PKCE
parameters).
2. The user authenticates on the issuer side.
3. The issuer returns a short-lived `code` to the registered `redirect_uri`.
4. The app exchanges the `code` (+ PKCE `code_verifier`) for tokens at `/token`.
PKCE with the `S256` method is mandatory for public clients — it protects the `code` exchange
from interception.
## Where Veriqa fits
Veriqa sits **between steps 2 and 3** — in place of "the user authenticates". Instead of a
sign-in form, the issuer opens a **Veriqa transaction**: it shows a QR code / entry point, waits for
confirmation in a trusted channel, builds a `ClaimsPrincipal` from the result and hands it to
OpenIddict to issue the `code`. From there it is plain OIDC (`code` → `/token` → tokens).
From starting the transaction to issuing OpenIddict tokens.
The key point: Veriqa **does not replace OpenIddict** — token issuance, discovery, JWKS and
refresh stay with it. Veriqa only supplies user authentication through a trusted channel.
## The channel adapter in the flow
The channel adapter (Telegram / WhatsApp / other channels / Email) links the transaction to a
specific messenger: it delivers the confirmation request to the user, receives their response
and returns the resolved identity to the transaction. Each channel is a separate adapter, and
the set is extensible.
## Resolved identity and claims
After confirmation Veriqa builds an identity snapshot and maps it to OIDC claims:
| Claim | Meaning |
| - | - |
| `sub` | Stable subject identifier (required) |
| `amr` | Authentication method — the confirmation channel type (`telegram`, `whatsapp`, `max`, `email`). In the `id_token` it is an **array of strings** even for a single method: `"amr": ["telegram"]` (OIDC Core 1.0 §2). The same method is also visible in the `access_token` and the `/connect/userinfo` response |
| `auth_time` | When authentication completed |
| `name`, `email`, … | Extra claims from the resolved identity — depend on the channel and requested scopes |
| `channel_type`, `channel_user_id` | The confirmation channel and the user's id inside it — **only when the `channel` scope is requested** |
`channel` is a Veriqa-specific scope and it is the gate for the channel claims. If you do not request
it, neither claim reaches the `access_token`, the `id_token` or the `/connect/userinfo` response.
Add `channel` to your RP's requested scopes and to the client's `AllowedScopes` if your application
reads them.
`avatar` is the other Veriqa-specific scope, and it is the gate for `picture`. The value is the image
itself, a `data:image/…;base64,…` URI (a channel that exposes a public link may give an `https`
URL instead). It travels only in the `access_token` and so reaches you only through
`/connect/userinfo`; it is never put into the `id_token`. Request `avatar` (and add it to the
client's `AllowedScopes`), fetch the claims from userinfo (`GetClaimsFromUserInfoEndpoint = true` in
ASP.NET Core), and treat a missing `picture` as "no avatar". In ASP.NET Core `picture` is not mapped
into the principal by default, and it should not be: a data URI in the authentication cookie inflates
the request headers. Take the image from the userinfo JSON in `OnUserInformationReceived` and keep it
in your own store.
`/connect/userinfo` returns only the claims permitted by scopes and configuration. Override the
mapping with your own `IClaimsMapper`.
`preferred_username` is a hint, not an identity. On the Email channel (Push mode) it carries the
sender display name — a string the sender types into the `From` header themselves. SPF, DKIM and
DMARC do **not** verify it: they cover the domain and the address, not the displayed name.
Veriqa sanitizes the value (bidi overrides and control characters are removed, the length is
capped), but it cannot verify it.
The user identifier is `sub` alone; the verified address is `email` next to `email_verified`.
Never match accounts on `preferred_username` and never present it as a verified name.
## Step-up and confirmation in OIDC terms
`2FA` and `step-up` are not separate transaction types but overlays: `2FA` over `login`,
`step-up` over `confirmation`. It is the same Veriqa transaction model with extra context, so
confirming a sensitive action goes through the same trusted channel as a normal sign-in.
## What Veriqa is NOT
Veriqa is **not** an identity provider, **not** an account manager and **not** a registration
system. It adds an authentication and linking method to your existing setup without owning your
user base.
## Further reading
Wire Veriqa into an ASP.NET Core host.
The pre-production checklist.
# Sign-in methods compared
Source: https://veriqa.app/docs/concepts/sign-in-comparison
How a password, an SMS code, social login, a passkey and Veriqa compare: what the integrator sets up, what the user does at sign-in and how long it takes — with the source of every figure.
What the integrator sets up, and what the user does and how long it takes at the first and at a
repeat sign-in. The times of the other methods come from published reports and our own
measurements; the Veriqa times are all our own measurements. The setup answers and the effort levels
are our assessment, and the rules behind them are below the tables.
## Sign-in: user effort and time
Effort is a level from 1 to 5 on the [scale below](#how-user-effort-is-counted). A number in square
brackets points to the [source](#sources-for-the-sign-in-times) of the time.
| Sign-in method | Scenario | First sign-in | Repeat sign-in | Time: first / repeat |
| - | - | - | - | - |
| Password | Password only | 5 | 4 | no data / \~30 s [\[3\]](#sources-for-the-sign-in-times) |
| Password | With a second factor | 5 | 5 | no data / \~69 s [\[1\]](#sources-for-the-sign-in-times) |
| SMS code | — | 4 | 4 | no data / \~17 s [\[4\]](#sources-for-the-sign-in-times) |
| Social login | — | 3 | 1 | 30 s / **3.4 s** [\[6\]](#sources-for-the-sign-in-times) |
| Passkey | The key is on the same device | 5 | 1 | no data / **3–15 s** [\[1, 2, 3\]](#sources-for-the-sign-in-times) |
| Passkey | The key is on the phone, the sign-in is on a computer | 5 | 3 | 24 s / 18 s [\[7\]](#sources-for-the-sign-in-times) |
| **Veriqa** | On a computer, automatic sign-in | 1 | 1 | **3.5 s** [\[5\]](#sources-for-the-sign-in-times) |
| **Veriqa** | On a computer, with confirmation | 2 | 2 | **6 s** [\[5\]](#sources-for-the-sign-in-times) |
| **Veriqa** | On a phone, automatic sign-in | 1 | 1 | **2.4 s** [\[5\]](#sources-for-the-sign-in-times) |
| **Veriqa** | On a phone, with confirmation | 1 | 1 | **4 s** [\[5\]](#sources-for-the-sign-in-times) |
Times of up to 15 seconds are in bold. With Veriqa the first sign-in takes as long as a repeat one,
so the table shows a single time.
## Setup: what the integrator does
| Sign-in method | Can an AI agent do the setup? | How much of a person's time does it take? | Is a third party involved? |
| - | - | - | - |
| Password | The agent writes the code; a person sets up the email provider and DNS | Hours | An email provider, no approval |
| SMS code | The agent writes the code; a person signs the contract and registers the sender | Hours of work and days of waiting | An SMS provider and the carriers, with approval |
| Social login | The agent writes the code; a person opens the developer accounts and creates the applications | Hours for each provider, plus waiting for the review | Each provider, with a review of the application |
| Passkey | The agent writes the code; a person sets up the email provider for the fallback sign-in | Minutes for the passkeys, hours with the fallback sign-in | Not needed for the passkeys; an email provider for the fallback |
| **Veriqa** | **The agent does everything except creating the bot** | **15–30 minutes** | The messenger: a Telegram bot is created right away, with no approval |
What the setup includes:
* **Password** — password storage and hashing, a sign-up form, email confirmation, password reset,
an email provider, a second factor, brute-force protection, support for users who forget their
password.
* **SMS code** — a contract with an SMS provider, sender registration with the carriers, code
generation and verification, rate limits and protection against SMS fraud, a charge for every
message.
* **Social login** — for each provider: a developer account (paid, in Apple's case), an application,
a consent screen, redirect URIs, keys, the provider's review of the application, and your own code
that handles the response and links accounts.
* **Passkey** — a WebAuthn server library, the relying party ID and origin settings, a table for the
users' public keys, sign-up and sign-in code on the server and on the front end. The integrator
creates no secrets. A fallback sign-in is needed for a lost or new device — usually a link by
email, and that takes an email provider.
* **Veriqa** — run the container, point it at a database, create a bot and set its token, issue two
token certificates, declare an OIDC client, put the service behind a reverse proxy and connect your
application as you would to any OIDC provider. Step by step in
[Quickstart self-hosted](/docs/quickstart/self-hosted).
## Sources for the sign-in times
1. [Microsoft Learn — Passkeys (FIDO2) authentication method in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-passkeys-fido2).
Signing in with a synced passkey takes 3 seconds, versus 69 seconds for a password with a
traditional second factor. Data on Microsoft consumer accounts.
2. [FIDO Alliance — Passkey Index, October 2025](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/).
A passkey sign-in takes 8.5 seconds on average. Data reported by nine member companies of the
alliance.
3. [Google Security Blog — Making authentication faster than ever: passkeys vs. passwords](https://security.googleblog.com/2023/05/making-authentication-faster-than-ever.html).
A sign-in takes 14.9 seconds on average with a passkey and 30.4 seconds with a password. Data on
about 100 million sign-ins to Google accounts, March–April 2023.
4. [Google for Developers — Mercari's passkey authentication speeds up sign-in 3.9 times](https://developers.google.com/identity/passkeys/case-studies/mercari).
A sign-in with an SMS code takes 17 seconds on average. Mercari's data on its own users.
5. Our own measurement, taken in Telegram on a phone where the messenger was already installed and
signed in. For a sign-in on a computer, the user opens the camera, points it at the QR code and
taps the link; with confirmation they also tap Confirm. In other messengers the time may differ.
6. Our own measurement of sign-in with Google on the Logto sign-in page. The first sign-in took
30 seconds in a browser already signed in to Google: Google still asked for the second factor. A
repeat sign-in took 3.4 seconds and one click. In a browser not signed in to Google, the first
sign-in also requires typing the account password.
7. Our own measurement of a passkey kept on the phone, on the same Logto sign-in page. Creating the
key took 24 seconds after some practice and about a minute the very first time; creating the
account is not counted. A repeat sign-in on a computer took 18 seconds.
Where the sources give no time for the first sign-in, the table shows only the repeat one. The
figures were collected in different ways: a survey of companies, the statistics of one vendor and
our own measurement. Read them as an order of magnitude, not as one experiment.
## How user effort is counted
We count what the user does in a clean browser, with no password manager: every click, tap, scan
and typed field. Scanning a QR code with the phone camera is one action.
| Level | What the user does |
| - | - |
| 1 | One or two actions, nothing to type |
| 2 | Three actions, nothing to type |
| 3 | Four or more actions, or one field to type |
| 4 | Two fields to type |
| 5 | Three or more fields to type |
The actions in each scenario:
| Method and scenario | First sign-in | Repeat sign-in |
| - | - | - |
| Password only | Type the email and the password twice, then open the mailbox and click the confirmation link. | Type the username and password. |
| Password with a second factor | Type the email and the password twice, then open the mailbox and click the confirmation link. | Type the username and password, then open another app and type the second-factor code. |
| SMS code | Type the phone number, wait for the SMS, type the code. | Type the phone number, wait for the SMS, type the code. |
| Social login | Click the button, complete the social network's second-factor check and accept the consent screen. In a browser not signed in to the social network, the user also types its username and password. | Click the button. |
| Passkey, the key is on the same device | Create an account another way, usually with a password or a code, then create the key and confirm with biometrics. | Click the button and confirm with a fingerprint or a face scan. |
| Passkey, the key is on the phone, the sign-in is on a computer | Create an account another way, usually with a password or a code, then create the key and confirm with biometrics. | Click the button, choose another device, scan the QR code with the phone and confirm with biometrics on it. Bluetooth has to be on. |
| Veriqa on a computer, automatic sign-in | Scan the QR code with the phone camera and tap the link. | The same. |
| Veriqa on a computer, with confirmation | Scan the QR code with the phone camera, tap the link and tap Confirm. | The same. |
| Veriqa on a phone, automatic sign-in | Tap the messenger button on the sign-in page. | The same. |
| Veriqa on a phone, with confirmation | Tap the messenger button on the sign-in page and tap Confirm. | The same. |
## How the setup is assessed
Three questions for each method: can an AI agent do the whole setup, how much of a person's time it
takes, and whether a third party has to be involved. The answers are our assessment; only the
Veriqa time was measured.
How Veriqa differs from passkeys, messenger sign-in widgets, SMS codes and the device flow in
substance rather than in figures is covered in [How Veriqa differs](/docs/concepts/comparison).
# Channel setup
Source: https://veriqa.app/docs/guides/channels
End-to-end setup for every shipping channel: registering the adapter in code, its configuration section and its webhook.
A channel is the trusted app where the user confirms sign-in. Veriqa ships eight adapters — each
has its own section below — and the setup is identical whether you run Veriqa
[embedded](/docs/quickstart/dotnet) or [self-hosted](/docs/quickstart/self-hosted).
Every channel follows the same three steps: register the adapter in code, fill in its
`Veriqa:Channels:{Channel}` configuration section, and make its webhook reachable from the internet.
```csharp Program.cs theme={null}
adapters.AddTelegram();
adapters.AddWhatsApp();
adapters.AddMax();
adapters.AddInstagram();
adapters.AddMessenger();
adapters.AddWeChat();
adapters.AddViber();
adapters.AddEmail();
```
**Every channel is its own package.** The base set installs as one metapackage,
`Veriqa.Core.BaseChannels` (Telegram, WhatsApp, Email); MAX, Instagram, Messenger, WeChat and Viber are not part of it and
are installed explicitly — `dotnet add package Veriqa.Core.ChannelAdapter.Max`,
`dotnet add package Veriqa.Core.ChannelAdapter.Instagram`,
`dotnet add package Veriqa.Core.ChannelAdapter.Messenger`,
`dotnet add package Veriqa.Core.ChannelAdapter.WeChat`,
`dotnet add package Veriqa.Core.ChannelAdapter.Viber`. Need a single channel? Install
just that package (`Veriqa.Core.ChannelAdapter.Telegram`, `.WhatsApp`, `.Max`, `.Instagram`, `.Messenger`, `.WeChat`, `.Viber`, `.Email`): only
its own SDK comes with it, so a mail library never lands in a deployment that signs users in
through Telegram. The registration call does not depend on that choice —
`adapters.AddTelegram()` is written the same either way. Calling `Add*()` without the channel's
package is a compile error, not a silently missing channel.
Registering an adapter is not the same as enabling it. An adapter activates only when its section
carries `"Enabled": true` — otherwise only its options and startup validator are registered, and
the channel never appears on the sign-in page. Add only the channels you use.
Bot tokens, access tokens and SMTP passwords are secrets. Keep them in user-secrets during
development and in a secret store in production — never in a committed `appsettings.json`.
## Inbound verification level
Every channel section carries a required `InboundVerification` key — it declares **how the channel
verifies that an incoming event is authentic**. You declare the fact: Veriqa only
sees that a check ran and passed, never what the check was.
A deployment where a shipped channel is enabled and the value is missing or not one of the
allowed values **does not start**. State the value in the sections of disabled
channels too — then flipping `Enabled` to `true` does not break startup. The startup check
covers the same set of channels the core builds its list of enabled channels from. All shipped
channels are in it; a channel from a third-party adapter plugged in with a bare `AddChannel` call
is not, so the key in its section is required but not checked at startup — see
[Your own channel adapter](/docs/guides/custom-channel-adapter).
The values of the shipped channels follow the mode you run them in:
| Channel | Mode | Value | Provided by |
| - | - | - | - |
| Telegram | `UpdateMode: Webhook` | `shared_secret` | The `X-Telegram-Bot-Api-Secret-Token` header secret, compared in constant time |
| Telegram | `UpdateMode: Polling` | `outbound_fetch` | There are no incoming requests: the service fetches the updates itself |
| MAX | `UpdateMode: Webhook` | `shared_secret` | The `X-Max-Bot-Api-Secret` header secret, hashes compared in constant time |
| MAX | `UpdateMode: Polling` | `outbound_fetch` | There are no incoming requests: the service fetches the updates itself |
| WhatsApp (Meta Cloud API) | webhook | `signature` | HMAC-SHA256 of the request body, header `X-Hub-Signature-256` |
| WhatsApp (Twilio) | webhook | `signature` | HMAC-SHA1 of the webhook URL and the sorted form parameters, header `X-Twilio-Signature` |
| Instagram | webhook | `signature` | HMAC-SHA256 of the request body, header `X-Hub-Signature-256` |
| Messenger | webhook | `signature` | HMAC-SHA256 of the request body, header `X-Hub-Signature-256` |
| WeChat | webhook | `shared_secret` | SHA-1 over the account's Token, the timestamp and the nonce, query parameter `signature`, compared in constant time |
| Viber | webhook | `signature` | HMAC-SHA256 of the request body keyed with the bot token, header `X-Viber-Content-Signature`, compared in constant time |
| Email | `PushEnabled: true`, `Inbound:Provider: Webhook` | `shared_secret` | The incoming webhook secret is checked by the adapter's own endpoints |
| Email | `PushEnabled: true`, `Inbound:Provider: ImapPolling` | `outbound_fetch` | There are no incoming requests: the service reads the mailbox itself |
| Email | `PushEnabled: false` (default) | `shared_secret` | Pull mode receives no incoming request from a platform at all; the value is kept the same as in Push mode |
A channel in `Polling` mode — Email reading its mailbox over IMAP included — that declares anything
other than `outbound_fetch` produces a `Warning` at startup; startup continues. The full list of values is in the
[configuration reference](/docs/reference/configuration).
## Settings every chat channel shares
Two optional keys can be added to the section of any channel — a shipped one, a channel of your own adapter
or an HTTP channel. Neither has to be set: a deployment that says nothing keeps the behavior
described on this page.
| Key | Default | What it does |
| - | - | - |
| `SpareStartCodeEnabled` | `true` | Turns the channel's spare start code on or off — the short code the sign-in page offers when the link opened the chat and nothing started there. With `false` no code is prepared for the channel and no page shows its block. It applies to a channel that keeps a working link and declares the code as a second way to start — Instagram and Messenger among the shipped ones; a channel whose only way to start is the code is not affected by it |
| `BotName` | not set | The name the user sees the chat under — a bot username, an account handle, the name of a Page. It is shown as written, with nothing put in front, and is not translated. A name set here wins over the name the channel reports by itself. A name set in the host configuration also applies to a tenant that runs its own bot until that tenant sets its own name, so that tenant's pages would name the wrong bot: where tenants have their own bots, set the name for each tenant or do not set it at all — the channel then reports it if it can, otherwise the texts go without a name. A blank value means "not set". A name longer than 32 characters is ignored with a warning in the log |
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Messenger": {
"BotName": "Acme Support",
"SpareStartCodeEnabled": true
}
}
}
}
```
Both keys are set at the same level as the channel's credentials: in the host configuration
or for a single tenant. The name appears in two texts on the pages — the caption over the
spare code, "Send the code in the chat with the bot Acme Support if the bot has not sent you a
confirmation", and the line that tells the user to confirm in the chat. The sign-in page puts that
line on its status line for every chat channel once the bot has answered the start of a sign-in:
the user is in the conversation, and the answer is now awaited there. Without a name both texts
are worded without one; the name of the channel is never put in its place.
Switching the spare code off stops the pages from offering it and stops a code from being issued on
behalf of that channel. It does not make the channel refuse a code: a transaction has one code, and
a code issued for another channel of the same sign-in is still accepted there.
## Telegram
**What you need:** a bot created via [@BotFather](https://t.me/BotFather) and its token.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Telegram": {
"Enabled": true,
"InboundVerification": "shared_secret",
"BotToken": "YOUR_BOT_TOKEN",
"BotUsername": "your_login_bot",
"UpdateMode": "Webhook",
"WebhookBaseUrl": "https://auth.your-domain.com",
"WebhookSecretToken": "YOUR_RANDOM_SECRET"
}
}
}
}
```
| Key | Notes |
| - | - |
| `UpdateMode` | `Webhook` (default) or `Polling` |
| `WebhookBaseUrl` | Public HTTPS base URL of the Veriqa host — required in `Webhook` mode |
| `WebhookSecretToken` | Arbitrary random string; Telegram echoes it back in every update |
| `BotUsername` | Used to build the `t.me` deep link behind the QR code |
| `DeepLinkBaseUrl` | Defaults to `https://t.me/`; override only for a custom deep-link host |
In `Webhook` mode Veriqa **registers the webhook itself** on startup (and removes it on shutdown)
— you do not call `setWebhook` by hand. The endpoint is
`POST {WebhookBaseUrl}/api/channels/telegram/webhook`, and each request is authenticated by the
`X-Telegram-Bot-Api-Secret-Token` header against `WebhookSecretToken`.
`Polling` mode needs no public URL, which makes it the practical choice for local development —
switch to `Webhook` for anything deployed. It also allows only **one poller per bot**: the Bot API
hands the update queue to a single `getUpdates` caller, so a second process on the same token
receives nothing while its polling loop repeats the `409` refusal of the Bot API — logged as
`Conflict: terminated by other getUpdates request`, without the number.
The health report does not catch this — the token is valid, so the channel reports itself
`healthy`; see
[running more than one instance](/docs/quickstart/self-hosted#running-more-than-one-instance).
Telegram does not share the user's email address, and gives the phone number only when Veriqa asks for it in
the chat. Both can be collected inside the sign-in:
[Phone number](/docs/guides/phone-number),
[Progressive claim enrichment](/docs/concepts/claim-enrichment).
## WhatsApp
**What you need** (Meta Cloud API, the default provider): a Meta app with WhatsApp Business, a phone
number ID and a permanent access token from the [Meta for Developers](https://developers.facebook.com/)
console. Delivery through Twilio is set up [below](#delivery-through-twilio).
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"WhatsApp": {
"Enabled": true,
"InboundVerification": "signature",
"Provider": "MetaCloudApi",
"BusinessPhoneNumber": "+15551234567",
"MetaCloudApi": {
"PhoneNumberId": "YOUR_PHONE_NUMBER_ID",
"AccessToken": "YOUR_ACCESS_TOKEN",
"AppSecret": "YOUR_APP_SECRET",
"WebhookVerifyToken": "YOUR_VERIFY_TOKEN",
"GraphApiVersion": "v26.0"
}
}
}
}
}
```
`BusinessPhoneNumber` is the number users message, in E.164 format — it is what the deep link and
QR code point at.
Unlike Telegram, the webhook is registered **on Meta's side**. In the app's WhatsApp → Configuration
panel set the callback URL to `https://auth.your-domain.com/api/channels/whatsapp/webhook`, paste
the same `WebhookVerifyToken`, and subscribe to the `messages` field. Meta verifies the URL with a
`GET` carrying `hub.mode` and `hub.verify_token`; Veriqa answers it automatically once the channel
is enabled and the host is publicly reachable.
Every subsequent `POST` is signed by Meta with `X-Hub-Signature-256` and validated against
`AppSecret` — a missing or wrong `AppSecret` means valid deliveries get rejected.
Two delivery providers ship with the channel: the **Meta Cloud API** (`MetaCloudApi`, the default)
and **Twilio** (`Twilio`). The `Provider` key picks which of them is registered and declares which
provider the installation expects; it is matched at startup against the one actually registered.
A provider of your own plugged in with `UseWhatsAppProvider()` wins over the shipped one,
and its code goes into `Provider`. A mismatch stops the host with an error naming both codes.
Such a provider must fill `SenderUserId` of every inbound message with the sender's business-scoped
user ID (BSUID) — a message without it is rejected — and it receives the BSUID as the recipient of
every outbound message.
### Delivery through Twilio
**What you need:** a Twilio account (Account SID and Auth Token), a WhatsApp sender — the Twilio
Sandbox for WhatsApp to try it out, a registered sender in production — and a `twilio/quick-reply`
content created in the Twilio Content Template Builder.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"WhatsApp": {
"Enabled": true,
"InboundVerification": "signature",
"Provider": "Twilio",
"BusinessPhoneNumber": "+15551234567",
"Twilio": {
"AccountSid": "YOUR_ACCOUNT_SID",
"AuthToken": "YOUR_AUTH_TOKEN",
"QuickReplyContentSid": "YOUR_CONTENT_SID",
"WebhookUrl": "https://auth.your-domain.com/api/channels/whatsapp/webhook"
}
}
}
}
}
```
`BusinessPhoneNumber` is the Twilio sender number (the sandbox number when you use the Sandbox).
`AccountSid`, `AuthToken`, `QuickReplyContentSid` and `WebhookUrl` are required when `Provider` is
`Twilio`: without any of them the host does not start, and the error names the key, never its value.
`ApiBaseUrl` is optional and defaults to `https://api.twilio.com`. A tenant keeps its Twilio
credentials in the same WhatsApp credentials group as the Meta ones.
**The quick-reply content.** The confirmation prompt is sent as one content with two buttons.
Create it with the `twilio/quick-reply` type and exactly these variables — Veriqa fills them in:
| Variable | Filled with |
| - | - |
| `1` | the prompt text (up to 1024 characters; a longer one is not sent) |
| `2` | the title of the confirm button (truncated to 20 characters) |
| `3` | the id of the confirm button |
| `4` | the title of the decline button (truncated to 20 characters) |
| `5` | the id of the decline button |
```json twilio/quick-reply theme={null}
{
"twilio/quick-reply": {
"body": "{{1}}",
"actions": [
{ "title": "{{2}}", "id": "{{3}}" },
{ "title": "{{4}}", "id": "{{5}}" }
]
}
}
```
The prompt answers the user's own message, so it goes out inside the 24-hour session and needs no
WhatsApp template approval. Do not submit this content for approval.
**The webhook.** In the Twilio console set the sender's incoming-message URL (for the Sandbox:
*When a message comes in*, method `POST`) to
`https://auth.your-domain.com/api/channels/whatsapp/webhook`. Twilio signs every request with
`X-Twilio-Signature`, computed over the URL **it** knows, so `WebhookUrl` must be that same URL
character for character — scheme, host, port, path and query. Behind a proxy Veriqa sees its own
internal address, which is why the URL comes from configuration and not from the request; a
mismatch rejects every inbound message with `403`. A tenant with its own webhook route sets its
own `WebhookUrl`. Twilio has no `GET` handshake: a `GET` to the webhook is rejected.
With the Sandbox, each test phone joins it once by sending `join ` to the sandbox number;
the keyword is shown in the Twilio console.
This provider runs inside Veriqa. The `node/custom-channel-whatsapp-twilio` sample is a different
thing — an external adapter running as a separate service under its own channel type, see
[An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language).
### The prefilled message text
WhatsApp is the only shipped channel whose deep link carries a **message text** rather than an
opaque parameter: the user sees it in the input box and sends it as an ordinary message. Out of the
box that is a short multi-line message with the sign-in code on its own last line:
```text Base text (en) theme={null}
Sign in to Acme
Send this message to sign in.
Access code
auth_7Ks9mR3xYpL2nWq4tBvC8dEfG1hJkM5nP0qRsTuVwXy
```
The text is configured where every other channel text lives — in your host's locale files
(`wwwroot/locales/{language}.json`, see [Localization](/docs/guides/customization#localization)).
There are two keys: the full variant with the application name and a fallback without it, used when
the application name is unavailable. There is no text section under `Veriqa:Channels:WhatsApp`.
```json wwwroot/locales/en.json theme={null}
{
"Sign in to {app}\nSend this message to sign in.\n\nAccess code\n{code}":
"Sign in to {app}\nSend this message to sign in.\n\nAccess code\n{code}",
"Send this message to sign in.\n\nAccess code\n{code}":
"Send this message to sign in.\n\nAccess code\n{code}"
}
```
English is the base language, so in `en.json` the value repeats the key; a translated file such as
`ru.json` carries the same two keys with translated values.
Your own wording goes into the values of these keys (for English, edit `en.json`). Two slots are
available: `{app}` — the application being signed into — and `{code}`, the `auth_…` sign-in code.
The language follows the sign-in page, so translate every language you ship.
The application name comes from your OIDC client registration and is substituted **as is** — Veriqa
does not shorten it (the general slot value limit is 128 characters). Keep the name short: a long
one breaks the message layout and goes into the QR code in full, making it denser (see the warning
below).
**Text length directly affects how readable the QR code is.** The QR encodes the whole `wa.me`
URL, and the text inside it is percent-encoded: a space or a line break costs 3 characters, a
Cyrillic letter 6, a box-drawing character such as `═` 9. The longer the text, the higher the QR
version and the smaller its modules at the same on-screen size.
Hence the layout rules worth following:
* **keep the code on its own last line** — it is 48 characters long (`auth_` plus a 43-character
transaction id) and fits inside no frame;
* **do not use `*`, `_`, `~`** as graphics — WhatsApp reads them as markup (bold, italic,
strikethrough). `-`, `.`, `:`, `+`, `|` are safe;
* **frames with vertical bars** (`|…|`) only line up inside a monospace block (triple backticks) —
in an ordinary message the font is proportional and the frame falls apart. Some clients also show
the raw backticks in the input box and only render monospace in the sent message, so verify such
a layout on a device before rolling it out;
* **horizontal rules** (`------`) work in every client and need no wrapper.
Rough cost of a layout (the QR generator Veriqa ships; an 11-digit phone number, a 43-character
transaction id, error correction level M, application name "Acme Corp"). The "px per module" column
is the size of a single QR module at the sign-in page's normal 220 px box. The scanning threshold is
**2.86 px per module**. Rows are tagged with the language of the text — a non-Latin language costs
far more per character:
| Layout | Text language | URL length | QR version | px/module @220 | Fit for a QR code |
| - | - | - | - | - | - |
| No graphics — the default | English | 171 | v9 | 3.61 | Yes, with headroom |
| No graphics — the default | Russian | 388 | v13 | 2.86 | Yes, exactly at the threshold |
| Dashed rules + centered heading | Russian | 439 | v13 | 2.86 | Yes, exactly at the threshold |
| Dashed rules | Russian | 520 | v15 | 2.59 | No — below the threshold |
| `+--+` frame | English | 355 | v13 | 2.86 | No — the frame needs a monospace block |
| `+--+` frame | Russian | 682 | v17 | 2.37 | No |
| Unicode `╔══╗` frame | Russian | 1147 | v23 | 1.88 | No |
The Russian graphics-free and ruled-heading layouts sit **exactly at the threshold** with no
headroom left: any extra text raises the QR version and pushes the code out of spec. The English
default clears it comfortably. Framed layouts fail two gates at once: the Russian frames also miss
the density threshold (2.37 and 1.88), and every frame needs a monospace block — even the English
one, which sits exactly at the threshold, falls apart in an ordinary message. Hence the "No" in the
column.
The practical takeaway: if the message is also shown as a QR code, stay with the graphics-free
variant or limit yourself to horizontal rules — frames and box drawing make the QR hard for a
camera. Russian text costs roughly twice as much as English at the same layout: the same default
text is 388 characters against 171.
Ready-made samples of every layout follow; `{app}` and `{code}` are filled in by Veriqa.
```text No graphics — the default theme={null}
Sign in to {app}
Send this message to sign in.
Access code
{code}
```
```text Dashed rules + centered heading theme={null}
------------------------
SIGN IN TO {app}
------------------------
Just send this message.
Access code:
{code}
```
```text Dashed rules theme={null}
------------------------
SIGN IN TO {app}
------------------------
Send this message
to confirm the sign-in.
Access code
{code}
```
Framed layouts need a monospace block — the text itself is wrapped in triple backticks and the code
goes below the block (it does not fit inside a frame):
````text The "+--+" frame — only after checking it on a device theme={null}
```
+----------------------+
| SIGN IN TO {app} |
+----------------------+
| Just send this |
| message to confirm. |
+----------------------+
```
Access code
{code}
````
````text Unicode frame — the most expensive one, unfit for a QR code theme={null}
```
╔══════════════════════╗
║ SIGN IN TO {app} ║
╠══════════════════════╣
║ Just send this ║
║ message. ║
╚══════════════════════╝
```
Access code
{code}
````
The chosen sample goes into the locale file as a single line — line breaks are written as `\n`:
```json wwwroot/locales/en.json — the dashed-rules layout theme={null}
{
"Sign in to {app}\nSend this message to sign in.\n\nAccess code\n{code}":
"------------------------\n SIGN IN TO {app}\n------------------------\nJust send this message.\n\nAccess code:\n{code}",
"Send this message to sign in.\n\nAccess code\n{code}":
"------------------------\nJust send this message to sign in.\n------------------------\n\nAccess code:\n{code}"
}
```
Restyle **both** keys at once. The second one is the fallback without the application name; if you
only restyle the first, users whose application name is unavailable will see a different message.
Alignment around `{app}` is approximate: application names differ in length, so neither
space-centering nor the right edge of a frame lines up exactly. If you need a frame to look even,
use the variant without the application name.
## MAX
**What you need:** a MAX bot and its token. Configuration mirrors Telegram:
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Max": {
"Enabled": true,
"InboundVerification": "shared_secret",
"BotToken": "YOUR_BOT_TOKEN",
"BotPublicName": "Your Login Bot",
"UpdateMode": "Webhook",
"WebhookBaseUrl": "https://auth.your-domain.com",
"WebhookSecretToken": "YOUR_RANDOM_SECRET"
}
}
}
}
```
As with Telegram, Veriqa registers the webhook on startup in `Webhook` mode. On shutdown it removes
its own subscription — the one for the address it registered. The endpoint is
`POST {WebhookBaseUrl}/api/channels/max/webhook`, authenticated by the `X-Max-Bot-Api-Secret`
header. `BotPublicName` is the name shown to the user on the sign-in page.
## Instagram
The user confirms the sign-in in Instagram Direct, in a conversation with your Instagram account.
The channel runs on the Instagram API with Instagram Login and talks plain HTTP to
`graph.instagram.com` — the package carries no platform SDK.
**What you need:** an Instagram
[professional account](https://developers.facebook.com/docs/instagram-platform/overview) (business
or creator), a Meta app from the [Meta for Developers](https://developers.facebook.com/) console
with that account connected, and from the console — the app secret, an access token of the account,
the account's ID and its username.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Instagram": {
"Enabled": true,
"InboundVerification": "signature",
"AppSecret": "YOUR_APP_SECRET",
"WebhookVerifyToken": "YOUR_VERIFY_TOKEN",
"AccessToken": "YOUR_ACCESS_TOKEN",
"AccountId": "YOUR_INSTAGRAM_ACCOUNT_ID",
"Username": "your_login_account",
"GraphApiBaseUrl": "https://graph.instagram.com",
"GraphApiVersion": "v26.0"
}
}
}
}
```
| Key | Notes |
| - | - |
| `AppSecret` | The app secret of the Meta app; every incoming delivery is checked against it |
| `WebhookVerifyToken` | Arbitrary random string; you paste the same value into the webhook settings of the Meta app |
| `AccessToken` | Access token of the Instagram account the messages are sent from |
| `AccountId` | ID of that account — the sender of every message |
| `Username` | Username of that account; the `ig.me` link behind the QR code and the button is built from it. A leading `@` is dropped |
| `GraphApiBaseUrl` | Defaults to `https://graph.instagram.com`; must be an absolute HTTPS URL |
| `GraphApiVersion` | Defaults to `v26.0`; must start with `v` |
With `"Enabled": true`, `AppSecret`, `WebhookVerifyToken`, `AccessToken`, `AccountId` and `Username`
are required: a deployment with an empty one does not start, and the error message names the key.
`GraphApiBaseUrl` and `GraphApiVersion` have defaults; they are settings of the installation, and the
five required keys are the credentials of a tenant.
The sign-in link is `https://ig.me/m/{Username}?ref=auth_{transactionId}`. It opens the conversation
with your account, Instagram hands the `ref` value to Veriqa, and the user gets the question with
the Confirm and Decline buttons; the outcome arrives as a message of its own.
**When the `ref` value does not arrive,** the sign-in has a second way to start. The sign-in page
shows a short code below the Instagram button and QR code; the user sends it to your account as a
message — the digits alone, with or without spaces — and the sign-in starts as it does from the
link. A code that matches no sign-in — mistyped, expired or already used — gets the same answer
whatever the reason: check the code on the sign-in page and send it again, or open the sign-in page again.
**The code appears when it may be needed,** not as soon as the page opens, and only while your account
has not answered. Where it appears depends on the device the sign-in page is open on:
* **A computer or a tablet** shows the code 8 seconds after the page loads — unless the Instagram QR
code on the page is a [hop mode](/docs/reference/configuration#veriqahopmode) QR code. Then the code
waits until the phone opens Instagram, and the sign-in page shows it under the instruction to
continue on the phone 6 seconds later.
* **On a phone,** the code does not open by itself. Once the user has tapped the Instagram button
and come back to the page, a **Problem signing in** link appears under the status line, for the
channel tapped last; the link opens the code. A reload forgets the tap.
* **The page a phone opens from a [hop mode](/docs/reference/configuration#veriqahopmode) QR code that
covers several channels** leaves the choice of channel to that page. Tapping the Instagram button
switches the page to that channel: the title and the instruction change to "Continue in Instagram"
with the channel icon, the channel buttons disappear, and a **Back to channel selection** link below
brings the choice back without affecting the sign-in. When the user comes back from the app, the
**Problem signing in** link appears under the status line, and the code behind it comes with
**Copy and open Instagram**, as on the launch page. Once your account answers or the sign-in ends,
the way back to the choice is gone for good.
* **The launch page on the phone** under hop mode offers the code behind the same link 6 seconds after
it took the user into the app. There the code comes with one button, **Copy and open Instagram**: it
copies the code and opens the app. Once the code is opened, the Instagram button above it disappears
from the page.
A browser with JavaScript turned off shows the code from the start.
**The code goes away once your account answers.** As soon as the user is in the conversation and
the question with the buttons is on its way, the sign-in page hides the code and its link, does not
offer them again, and its status line tells the user to confirm in the chat — "You have moved to Instagram. Tap
the confirmation button in the chat with @your\_account if you want to continue signing in, or tap
cancel." The channel choice leaves the page with them — the QR codes, the buttons and the instruction
to continue on the phone: another channel could only lead into a refusal now. The title, the status
line, the countdown and **Cancel sign-in** stay. The same happens when the user comes back to a page
left open on the same phone. The sign-in page learns of the answer over its live connection; a page
that fell back to polling keeps the code and the channel choice on screen. The launch page on the
phone learns of the answer from the server while it is open and takes the link and the code away for
good; its Instagram button stays unless the code was opened. The code is switched off altogether by
[`SpareStartCodeEnabled`](#settings-every-chat-channel-shares).
**A tapped button that names no sign-in** — an Icebreaker tapped with no link behind it — is answered
with a hint saying exactly that: send the code shown on the sign-in page, or scan the QR code again.
A typed message that is neither a code nor a sign-in marker gets no answer: a person writing to your
account about something else is not asking to sign in. To switch the hint off, add
`"UnaddressedReplyEnabled": false` to the channel section: your account then stays silent on such a
tap, and the code keeps working. The key is optional and defaults to `true`. Both answers are texts
of the channel like any other — reword them in the [locale files](/docs/guides/customization#localization).
**The webhook** is registered on Meta's side, as with WhatsApp. In the Meta app set the callback URL
to `https://auth.your-domain.com/api/channels/instagram/webhook`, paste the same
`WebhookVerifyToken`, and subscribe to the fields `messages`, `messaging_postbacks` and
`messaging_referral`. Meta verifies the URL with a `GET` carrying `hub.mode`, `hub.verify_token` and
`hub.challenge`; Veriqa answers it automatically once the channel is enabled and the host is publicly
reachable. Every subsequent `POST` is signed with `X-Hub-Signature-256` — HMAC-SHA256 of the request
body keyed with the app secret — and a delivery with a missing or wrong signature is rejected
([Meta: webhooks](https://developers.facebook.com/docs/instagram-platform/webhooks)).
**Permissions:** `instagram_business_basic` and `instagram_business_manage_messages`
([Meta: send messages](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api)).
**A tenant with a Meta app of its own** gets the same address with its name as the last segment:
`https://auth.your-domain.com/api/channels/instagram/webhook/{tenant}`. Without the segment the
delivery belongs to the default tenant; a delivery for a tenant the channel is not enabled for gets
a `403` response. One Meta app serving the accounts of several tenants is not supported: the tenant is
read from the address, not from the account the event came to.
**Access is granted by Meta, not by Veriqa.** With the default Standard Access the app serves only
accounts you own or manage and users who have a role on the app. Signing in arbitrary users needs
Advanced Access, which takes Meta App Review and Business Verification — a verified legal entity
([Meta: Instagram Platform overview](https://developers.facebook.com/docs/instagram-platform/overview)).
Meta sends webhook notifications only to an app set to **Live**
([Meta: webhooks](https://developers.facebook.com/docs/instagram-platform/webhooks)).
Platform limitations:
* **The 24-hour window.** The account may write to a user only within 24 hours of that user's last
message to it ([Meta: send messages](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api)).
A sign-in fits inside it: every message of the flow answers a user who has just opened the link.
* **The referral may not arrive while the Meta app is in development mode.** The user opens the
`ig.me` link, and Instagram delivers nothing to the webhook. This is a limit of the platform, not
of the adapter. The sign-in then starts with the code shown on the sign-in page: the user sends
it to the account as a message.
* **The first tap of a new user may arrive without the `ref` value.** When the user has never
written to your account, opening the `ig.me` link delivers nothing by itself: Instagram reports
the link together with the user's first action there — a tapped Icebreaker or a sent message
([Meta: ig.me links](https://developers.facebook.com/documentation/business-messaging/instagram-messaging/features/ig-me-links)).
Set up Icebreakers for the account before you offer the channel, so that a new user has a button
to tap — but that first tap may still reach Veriqa without the `ref` value. The user then gets the
hint (unless you switched it off) and goes on with the code shown on the sign-in page, or scans
the QR code again: the
conversation exists by then, and Instagram reports the link at once. The link works in the
Instagram mobile app only.
* **The sign-in link does nothing from the open conversation with the account.** When the user already has the conversation with your account open in the Instagram app, the `ig.me` link just returns them to it and the `ref` value never reaches Veriqa — the sign-in does not start. From any other screen of the app the link works. The user only has to send the code shown on the sign-in page into that conversation, or leave the conversation and open the link again. Hop mode and its redirect style make no difference. Observed on Instagram for iPhone in October 2026; the behavior belongs to the platform and may change.
* **One event per delivery.** When Meta packs several events into one webhook delivery, Veriqa
processes one of them and ignores the rest: an event that names a sign-in (an opened link, a
pressed button) comes before a shared phone number, and that before any other message.
* **The text of the question is truncated to 640 characters** — the limit of a message with buttons
([Meta: button template](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api/button-template)).
* **No user language.** Instagram does not tell the adapter the user's language, so the
messages follow the language of the sign-in.
* **The phone number** is asked for with a button that Instagram shows only in its mobile app and
only to a user whose profile has a phone number — see [phone number](/docs/guides/phone-number#instagram).
## Messenger
The user confirms the sign-in in Messenger, in a conversation with your Facebook Page. The channel
runs on the Messenger Platform and talks plain HTTP to `graph.facebook.com` — the package carries no
platform SDK.
**What you need:** a Facebook Page, a Meta app from the
[Meta for Developers](https://developers.facebook.com/) console with that Page connected, and from
the console — the app secret, a Page access token, the Page's ID and its username.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Messenger": {
"Enabled": true,
"InboundVerification": "signature",
"PageId": "YOUR_PAGE_ID",
"PageName": "your.page.username",
"PageAccessToken": "YOUR_PAGE_ACCESS_TOKEN",
"AppSecret": "YOUR_APP_SECRET",
"WebhookVerifyToken": "YOUR_VERIFY_TOKEN",
"GraphApiBaseUrl": "https://graph.facebook.com",
"GraphApiVersion": "v26.0"
}
}
}
}
```
| Key | Notes |
| - | - |
| `PageId` | ID of the Facebook Page — the sender of every message |
| `PageName` | Username of that Page; the `m.me` link behind the QR code and the button is built from it. A leading `@` is dropped. Not required with `DeepLinkByPageId` switched on |
| `DeepLinkByPageId` | Optional, defaults to `false`. With `true` the link is built from `PageId` instead of `PageName` — see below |
| `PageAccessToken` | Page access token the messages are sent with |
| `AppSecret` | The app secret of the Meta app; every incoming delivery is checked against it |
| `WebhookVerifyToken` | Arbitrary random string; you paste the same value into the webhook settings of the Meta app |
| `GraphApiBaseUrl` | Defaults to `https://graph.facebook.com`; must be an absolute HTTPS URL |
| `GraphApiVersion` | Defaults to `v26.0`; must start with `v` |
With `"Enabled": true`, `PageId`, `PageAccessToken`, `AppSecret`, `WebhookVerifyToken` and
`GraphApiVersion` are required, `PageName` is required unless `DeepLinkByPageId` is on, and so is
`InboundVerification` — its value for Messenger is `signature`: a deployment with an empty one does
not start, and the error message names the key. `GraphApiBaseUrl` has a default. `GraphApiBaseUrl`
and `GraphApiVersion` are settings of the installation; `PageId`, `PageName`, `PageAccessToken`,
`AppSecret` and `WebhookVerifyToken` are the credentials of a tenant.
The sign-in link is `https://m.me/{PageName}?ref=auth_{transactionId}`. It opens the conversation
with your Page, Messenger hands the `ref` value to Veriqa, and the user gets the question with the
Confirm and Decline buttons; the outcome arrives as a message of its own. In a conversation that
already exists the `ref` value arrives as a referral event; in a new one Messenger reports it with
the tap of the Get Started button ([Meta: m.me links](https://developers.facebook.com/docs/messenger-platform/discovery/m-me-links)) —
and that first tap may reach Veriqa without the `ref` value.
**If the link opens the app store instead of the conversation,** add `"DeepLinkByPageId": true` to
the channel section. The link becomes `https://m.me/{PageId}?ref=auth_{transactionId}`, and
`PageName` is no longer needed. The username link is the only form Meta documents, so it
stays the default; the Page ID link is for a Page whose username link does not open the
conversation in the Messenger app — observed on an iPhone with Messenger installed in October 2026.
**When the `ref` value does not arrive,** the sign-in has a second way to start. The sign-in page
shows a short code below the Messenger button and QR code; the user sends it to your Page as a
message — the digits alone, with or without spaces — and the sign-in starts as it does from the
link. A code that matches no sign-in — mistyped, expired or already used — gets the same answer
whatever the reason: check the code on the sign-in page and send it again, or open the sign-in page again.
The code appears and goes away on the conditions described for [Instagram](#instagram): it is shown
when it may be needed and hidden once your Page answers.
**On an iPhone, the way into Messenger may go through a browser page.** Under hop mode, once the page
notices that the phone has opened Messenger, the instruction to continue on the phone on the
sign-in page gets one more line: "On iPhone, a browser page with two buttons may open first. Tap the one that opens
Messenger, then confirm that you want to open the Messenger app." The sign-in page does not know the
phone's operating system, so the line is worded for an iPhone and shown for any phone; it
disappears together with the "Choose another way" button and once your Page answers. The launch
page on the phone, which does know the operating system, gives the same advice to an iPhone only.
**A tapped button that names no sign-in** — Get Started tapped with no link behind it — is answered
with a hint saying exactly that: send the code shown on the sign-in page, or scan the QR code again.
A typed message that is neither a code nor a sign-in marker gets no answer: a person writing to your
Page about something else is not asking to sign in. To switch the hint off, add
`"UnaddressedReplyEnabled": false` to the channel section: your Page then stays silent on such a
tap, and the code keeps working. The key is optional and defaults to `true`. Both answers are texts
of the channel like any other — reword them in the [locale files](/docs/guides/customization#localization).
**The webhook** is registered on Meta's side, as with WhatsApp. In the Meta app set the callback URL
to `https://auth.your-domain.com/api/channels/messenger/webhook`, paste the same
`WebhookVerifyToken`, and subscribe the Page to the fields `messages`, `messaging_postbacks` and
`messaging_referrals` — the last two are what delivers the `ref` of an `m.me` link
([Meta: messaging\_referrals](https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messaging_referrals)).
Meta verifies the URL with a `GET` carrying `hub.mode`, `hub.verify_token` and `hub.challenge`;
Veriqa answers it automatically once the channel is enabled and the host is publicly reachable.
Every subsequent `POST` is signed with `X-Hub-Signature-256` — HMAC-SHA256 of the request body keyed
with the app secret — and a delivery with a missing or wrong signature is rejected.
**A tenant with a Meta app of its own** gets the same address with its name as the last segment:
`https://auth.your-domain.com/api/channels/messenger/webhook/{tenant}`. Without the segment the
delivery belongs to the default tenant; a delivery for a tenant the channel is not enabled for gets
a `403` response. One Page per webhook address: one Meta app serving the Pages of several tenants is
not supported — the tenant is read from the address, not from the Page the event came to.
**Access is granted by Meta, not by Veriqa.** With the default Standard Access the app receives
events only for people who have a role on the app. Signing in arbitrary users needs Advanced
Access, which takes Meta App Review and Business Verification — a verified legal entity.
Platform limitations:
* **The referral may not arrive while the Meta app is in development mode.** The user opens the
`m.me` link, and Messenger delivers nothing to the webhook. This is a limit of the platform, not
of the adapter. The sign-in then starts with the code shown on the sign-in page: the user sends
it to the Page as a message.
* **The 24-hour window.** The Page answers a user within the standard messaging window of 24 hours
after that user's last message. A sign-in fits inside it: every message of the flow answers a user
who has just opened the link.
* **The profile needs permissions of its own.** The name and the picture are read with the Business
Asset User Profile Access feature, the language with the `pages_user_locale` permission. Without
them the sign-in still completes: the user is identified by the page-scoped ID, and the token
carries no `given_name`, `family_name`, `picture` or `locale`.
* **One event per delivery.** When Meta packs several events into one webhook delivery, Veriqa
processes one of them and ignores the rest: an event that names a sign-in (an opened link, a
pressed button) comes before a shared phone number, and that before any other message.
* **The text of the question is truncated to 640 characters** — the limit of a message with buttons.
* **The language of the messages** is the language of the user's Facebook profile when the app has
the `pages_user_locale` permission, and the language of the sign-in otherwise: the webhook itself
carries no language of the user.
* **The phone number** is asked for with a button that Messenger shows only to a user whose profile
has a phone number — see [phone number](/docs/guides/phone-number#messenger).
## WeChat
The user scans a QR code with WeChat and confirms the sign-in in the chat with your Official
Account. The channel talks plain HTTP to `api.weixin.qq.com` — the package carries no platform SDK.
**What you need:** a verified WeChat Service Account — the platform verifies a legal entity — and
from its console the AppID and the AppSecret, plus a Token you choose for the server configuration
of the account.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"WeChat": {
"Enabled": true,
"InboundVerification": "shared_secret",
"AppId": "YOUR_APP_ID",
"AppSecret": "YOUR_APP_SECRET",
"Token": "YOUR_SERVER_TOKEN",
"ApiBaseUrl": "https://api.weixin.qq.com"
}
}
}
}
```
| Key | Notes |
| - | - |
| `AppId` | AppID of the Official Account |
| `AppSecret` | AppSecret of the account; the access token of every outgoing call is obtained with it |
| `Token` | The Token of the account's server configuration; every incoming request is checked against it |
| `ApiBaseUrl` | Defaults to `https://api.weixin.qq.com`; must be an absolute HTTP or HTTPS URL |
With `"Enabled": true` the first three keys are required, and so is `InboundVerification` — its
value for WeChat is `shared_secret`: a deployment with an empty one does not start, and the error message
names the key. `ApiBaseUrl` is a setting of the installation; the other three are the credentials
of a tenant.
There is no link to build here: for every sign-in Veriqa asks the platform for a temporary QR code
whose scene is `auth_{transactionId}`, and the sign-in page shows that code. The user scans it with
WeChat — a user who does not follow the account yet follows it first — and gets the question with
two options under it; tapping one answers it, and the outcome arrives as a message of its own. The
code is valid for 30 minutes, longer than any sign-in.
**The webhook** is configured on the platform's side. In the server configuration of the account set
the URL to `https://auth.your-domain.com/api/channels/wechat/webhook`, enter the same `Token` and
choose the plaintext message mode. The platform checks the URL with a `GET` carrying `signature`,
`timestamp`, `nonce` and `echostr`; Veriqa answers it automatically once the channel is enabled and
the host is publicly reachable. Every request — that check and each `POST` after it — is signed with
SHA-1 over the Token, the timestamp and the nonce; a request with a missing or wrong signature, or
with a timestamp more than five minutes off the host's clock, is rejected.
**A tenant with an Official Account of its own** gets the same address with its name as the last
segment: `https://auth.your-domain.com/api/channels/wechat/webhook/{tenant}`. Without the segment
the request belongs to the default tenant; a request for a tenant the channel is not enabled for gets
a `403` response. The signature is checked with the Token of that tenant, and each account keeps an
access token of its own.
**Add your hosts to the IP allowlist of the account.** The platform accepts API calls only from
the addresses listed there. A call from any other address is refused, and the QR code of the
sign-in cannot be created.
Platform limitations:
* **The messaging window.** The account may send three messages within a minute after a scan and
five within 48 hours after a message from the user. A sign-in fits inside it: the question follows
the scan, the outcome follows the tapped option. A message outside the window is refused by the
platform and is not sent again.
* **Plaintext mode only.** A delivery in the encrypted mode is not read.
* **A slow answer repeats the question.** The platform delivers an event again when it gets no
answer within five seconds, and the channel does not tell that delivery from a new scan — the user
may get the question twice. Either copy answers the sign-in.
* **The OpenID and nothing else.** The platform gives an Official Account neither a name, nor a
picture, nor the user's language: the token carries the base claims only, `name` holds the
OpenID, and the messages are in the language of the sign-in.
* **No phone number.** An Official Account has no way to ask for a phone number in the chat, so the
channel does not provide one — see [phone number](/docs/guides/phone-number).
* **Scanning is the confirmed way in.** The channel button opens the content of the same QR code.
Whether a mobile browser hands that address over to the WeChat app, and what a system camera does
with the code, has not been confirmed — rely on WeChat's own scanner.
* **Several hosts, one account.** The access token is obtained with the platform's stable-token
call, so hosts serving one account do not revoke each other's tokens.
* **The name `wechat` is taken.** An [HTTP channel](/docs/guides/custom-channel-adapter) registered under
that name prevents the host from starting — rename it.
## Viber
The user confirms the sign-in in Viber, in a conversation with your bot. The channel talks plain
HTTP to `chatapi.viber.com` — the package carries no platform SDK.
**Not verified on the live platform.** The adapter has been run only against a mock of the Viber
Bot API; no sign-in has been made through a real Viber bot. What this section says about the
platform comes from the
[Viber REST Bot API documentation](https://developers.viber.com/docs/api/rest-bot-api/) — check
the channel with your own bot before you rely on it.
**A bot is a commercial product of Viber.** Since February 5, 2024 Viber issues new bots on
commercial terms only. Get the bot and its token from Viber before you plan a deployment.
**What you need:** a Viber bot, and from Viber — its authentication token and its URI.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Viber": {
"Enabled": true,
"InboundVerification": "signature",
"AuthToken": "YOUR_BOT_AUTH_TOKEN",
"BotUri": "yourbot",
"SenderName": "Your Product",
"WebhookBaseUrl": "https://auth.your-domain.com",
"ApiBaseUrl": "https://chatapi.viber.com"
}
}
}
}
```
| Key | Notes |
| - | - |
| `AuthToken` | Authentication token of the bot. Outgoing calls are authorized with it, and every incoming delivery is checked against it |
| `BotUri` | URI of the bot; the `viber://pa` link behind the QR code and the button is built from it |
| `SenderName` | Name the bot's messages are signed with. Defaults to `Veriqa`; at most 28 characters — the limit of the platform. A longer name in the application settings stops the application at startup; a longer name of a tenant is not checked at startup — messages of that tenant are not sent, and an `Error`-level entry is written to the log |
| `WebhookBaseUrl` | Public address of the host, scheme and host name; must be an absolute HTTPS URL |
| `ApiBaseUrl` | Defaults to `https://chatapi.viber.com`; must be an absolute URL |
With `"Enabled": true`, `AuthToken`, `BotUri` and `WebhookBaseUrl` are required, and so is
`InboundVerification` — its value for Viber is `signature`: a deployment with an empty one does not
start, and the error message names the key. `SenderName` and `ApiBaseUrl` have defaults. `WebhookBaseUrl` and `ApiBaseUrl` are settings of the installation; `AuthToken`,
`BotUri` and `SenderName` are the credentials of a tenant.
The sign-in link is `viber://pa?chatURI={BotUri}&context=auth_{transactionId}`. It opens the
conversation with your bot, Viber hands the `context` value to Veriqa, and the user gets the
question with the Confirm and Decline buttons; the outcome arrives as a message of its own.
**The webhook** of the installation's own bot is registered by Veriqa. Once the host accepts
requests, it calls `set_webhook` with the address `{WebhookBaseUrl}/api/channels/viber/webhook` and
subscribes to the `conversation_started` event; the user's messages are delivered to a webhook
without a subscription. Viber answers that call only after it has sent a check request to the
address and got `200` back, so the host has to be publicly reachable over HTTPS by then. A refused
registration is written to the log as an error and does not stop the host: the other channels keep
working, and the registration is not retried until the next start. On shutdown the host removes the webhook.
Every delivery carries the `X-Viber-Content-Signature` header — HMAC-SHA256 of the request body
keyed with the bot token, in hexadecimal form. A delivery with a missing or wrong signature
is rejected.
**A tenant with a bot of its own** gets the same address with its name as the last segment:
`https://auth.your-domain.com/api/channels/viber/webhook/{tenant}`. Veriqa registers only the
webhook of the installation's own bot. The webhook of a tenant's bot is registered by whoever sets
the tenant up: a `set_webhook` call with the token of that bot, the address with the `{tenant}`
segment and the `conversation_started` event type
([Viber: setting a webhook](https://developers.viber.com/docs/api/rest-bot-api/)).
Without the segment the delivery belongs to the default tenant; a delivery for a tenant the channel
is not enabled for gets a `403` response. The signature is checked with the token that resolves for
that tenant — its own, or the installation's when the tenant has none.
Platform limitations:
* **One message before the user answers.** After the conversation is opened the bot may send one
message; the next one goes through only after the user has written to the bot. A sign-in fits:
the question is that one message, and a tap on Confirm or Decline is the user's answer.
* **The tap shows in the chat.** A button of a Viber keyboard sends its value as a message from the
user, so the conversation shows a line starting with `vq_confirm_` or `vq_decline_`.
* **A sent message cannot be changed.** The outcome always arrives as a new message; it also removes
the question's buttons.
* **The profile is what the event carries**: the name, the picture and the language of the user. The
messages are in that language; without it, in the language of the sign-in.
* **The phone number** is asked for with the `share-phone` button, and Viber does not prove that
the number belongs to the user — see [phone number](/docs/guides/phone-number#viber).
* **No polling.** The Bot API delivers events to a webhook only, so the channel cannot run on a host
that is not reachable from the internet.
* **The name `viber` is taken.** An [HTTP channel](/docs/guides/custom-channel-adapter) registered under
that name prevents the host from starting — rename it.
## Email
Email is the only channel with two independent directions, and they can run together:
* **Pull (magic link)** — Veriqa emails a link, the user clicks it. On by default (`PullEnabled`).
* **Push — the one-tap email** — the user sends a pre-filled email to the inbound address (one
tap from a button or a QR code) and Veriqa reads it. Off by default (`PushEnabled`); it needs
inbound delivery and a sender verification policy. `Push` is the value in configuration;
"one-tap email" is what the mode is called for users.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Email": {
"Enabled": true,
"InboundVerification": "shared_secret",
"PublicBaseUrl": "https://auth.your-domain.com",
"PreferredMode": "Pull",
"PullEnabled": true,
"PushEnabled": false,
"TokenTtl": "00:05:00",
"MailResendInterval": "00:00:30",
"Outbound": {
"Provider": "Smtp",
"FromAddress": "login@your-domain.com",
"FromName": "Veriqa",
"Smtp": {
"Host": "smtp.your-provider.com",
"Port": 587,
"UseSsl": false,
"Username": "…",
"Password": "…"
}
}
}
}
}
}
```
`PublicBaseUrl` is **required** whenever the channel is enabled — magic links and QR codes are
built from it. `TokenTtl` is the lifetime of a magic link and of the tokens of the claims completion;
the token of the Push sign-in email stays valid until its transaction expires instead. `MailResendInterval`
is the shortest time between two emails of one sign-in — see
[Sending an email again](#sending-an-email-again). `Port: 587` with `UseSsl: false` means STARTTLS; `UseSsl: true` means implicit TLS
on port 465.
`RequireStartTls` (default `true`) applies when `UseSsl` is `false` and `Username` and `Password`
are set: if the server does not offer STARTTLS, the connection is refused, so the password is never
sent in clear text. Set it to `false` only for an internal relay you deliberately reach without TLS —
STARTTLS is then used only when the server offers it. An anonymous relay (no credentials) is not
affected.
`Username` and `Password` are set **together only**. Leaving both empty is a legitimate
configuration: that is a relay without authentication, the usual arrangement for an internal SMTP
server inside a company network. Setting **exactly one** of them, however, is refused at startup —
no authentication would be attempted at all, so the value you configured would silently never be
used. If Veriqa does not start with a message about `Username` and `Password`, either remove the
leftover value or set both.
### Which mode to choose
The magic link is on by default and needs nothing beyond outbound mail, so it is the cheaper
start. The one-tap email costs a way to receive mail and a sender policy — and it no longer requires
an inbound mail provider: Veriqa can read an ordinary mailbox itself, see
[One-tap email over IMAP](#one-tap-email-over-imap). It buys four things the magic link cannot:
* **Nothing has to reach the user for the sign-in to work.** The email travels the other way, so
the sign-in no longer depends on your outbound mail arriving: no spam folder, no greylisting, no
domain reputation. With `PullEnabled: false` the channel needs no SMTP at all — the options
validator asks for outbound settings only when Pull is on.
* **There is no link for a mail gateway to burn.** Corporate mail protection opens links in
incoming messages to check them, and a one-time magic link can be used up before the person
clicks it. An email the user sends carries no such link.
* **It proves control of the mailbox, not just that the message arrived.** Following a link shows that the
message reached someone — a forwarding rule or a shared mailbox included. Sending the email
shows that the sender controls the box, and the sender is checked against `VerificationPolicy`
(`DmarcAlignedPass` by default).
* **It is the same move the messengers use.** The user sends a ready-made message in one tap —
[the secret message](/docs/guides/supported-channels#secret-message). Email is that pattern with an
email instead of a chat message, so the sign-in page teaches one habit, not two.
The modes are independent and can run together: `PreferredMode` decides which one the sign-in page
offers first, and with Push first the sign-in page keeps the address field for a magic link as a
fallback while Pull is on.
### Push mode — the one-tap email
Push additionally needs the `Inbound` block. Incoming mail reaches Veriqa in one of two ways: an
inbound mail provider posts it to a webhook (below), or Veriqa reads a mailbox itself —
[One-tap email over IMAP](#one-tap-email-over-imap).
```json appsettings.json theme={null}
{
"Inbound": {
"Provider": "Webhook",
"InboundAddress": "login@your-domain.com",
"UsePlusAddressing": true,
"VerificationPolicy": "DmarcAlignedPass",
"WebhookSecretToken": "YOUR_SECRET"
}
}
```
Incoming mail is posted to `POST https://auth.your-domain.com/api/channels/email/inbound`,
authenticated by the `X-Email-Webhook-Secret` header. The endpoint accepts one flat JSON object in
Veriqa's own format — not the native payload of a mail service:
```json theme={null}
{
"messageId": "",
"from": "user@example.com",
"envelopeSender": "user@example.com",
"to": "login+TOKEN@your-domain.com",
"subject": "Sign in",
"body": "…",
"receivedAt": "2026-01-01T12:00:00Z",
"spf": "pass",
"dkim": "pass",
"dmarc": "pass"
}
```
An inbound service that posts in its own format (Mailgun, Postmark, SendGrid Inbound Parse, …)
therefore needs a small forwarder in front of the endpoint — a Cloudflare Email Worker or a function
of your own — that converts each message into this object. To handle a native format inside the
host instead, set `Provider` to `Custom` and register your own `IEmailInboundProcessor`.
In a multi-tenant installation the same route also accepts an optional tenant segment —
`POST https://auth.your-domain.com/api/channels/email/inbound/{tenant}` — and each tenant configures
its own inbound provider with its own segment. Mail arriving there is then handled with THAT
tenant's `Inbound` settings and webhook secret. Without the segment the route uses the
installation-wide settings.
`UsePlusAddressing` embeds the correlation token into the reply address (`login+{token}@…`), which
is how an inbound message is tied back to its sign-in transaction.
`VerificationPolicy` decides how much the sender is trusted — an email `From` is trivially
forged, so this is a security control, not a formality:
| Value | Behavior |
| - | - |
| `DmarcAlignedPass` | Default. Requires a DMARC-aligned pass — the recommended setting |
| `SpfOrDkimPass` | Weaker: SPF **or** DKIM passing is enough |
| `AllowListOnly` | Only domains listed in `AllowedDomains` |
Do not disable sender verification in production. Without it, anyone who can forge a `From`
header can complete someone else's sign-in.
### What the user sees after the tap
Without hop mode the QR code and the **"Send confirmation email"** button of the sign-in page carry
the email itself — a `mailto:` link with the recipient, the subject and the body filled in. No page of
Veriqa opens in between: the phone that scans the code, or the computer on which the button is
pressed, opens the mail client with the email ready to send.
* When its button is pressed, the sign-in page shows the line "The confirmation email is open in your
mail app. Send it to continue." by itself. When the user comes back to the page from the mail app
while that line is still on it, the line becomes "If you have already sent the email, wait for the
confirmation. It usually takes a few seconds." A reload forgets the tap.
* An email opened from the QR code reports nothing to the sign-in page until the email arrives; from
then on the page shows that the email was received and how the sign-in ended.
* A sender that passed verification is not yet a sign-in: when the sign-in has already been taken up
by the account of another channel, the email does not sign in, and instead of "Sender verified" the
page says "Sign-in has already started in another channel." ("Confirmation has already started in
another channel." on a confirmation page).
* Where the sign-in window asks — the default for Email, see "The emailed-link page" below —
the email does not sign the user in by itself: it proves the mailbox, and the sign-in page moves on
to the question "Do you confirm the sign-in?". **Yes** signs the user in; **No** declines the
sign-in, and the application gets `access_denied`. With `LoginConfirmation.Mode` set to `None` the
email completes the sign-in itself.
`Inbound:TokenInLocalPart` chooses the form of the recipient address, not the target:
| `TokenInLocalPart` | Recipient of the email | Where the sign-in token is carried |
| - | - | - |
| `false` (default) | `InboundAddress` as it is, or `login+{token}@your-domain.com` with `UsePlusAddressing` | the subject and the body of the email, and the plus segment when it is on |
| `true` | `{token}@your-domain.com` — needs a catch-all mailbox on the domain of `InboundAddress` | the address alone; the subject and the body stay short |
When the email cannot be built — an `InboundAddress` it cannot be addressed to (unset outside
Production, or without a domain while `TokenInLocalPart` is on), or a failure of the token store —
the reason is written to the log and the channel keeps what is left: with `PullEnabled` the sign-in
page shows the address field without the mail-client button and without the QR code; without it the
Email channel is not offered. An email too long for a QR code — a long prefilled email text —
leaves the channel with its button and without the QR code.
### Email under hop mode
In [hop mode](/docs/reference/configuration#veriqahopmode) the QR code of the Email channel carries the
hop link instead of the email, and the phone is still taken to the email itself. What it gets depends
on one thing —
whether sign-in with an emailed link (`PullEnabled`) is on:
| Emailed link (`PullEnabled`) | `RedirectStyle` | What the phone gets |
| - | - | - |
| on | `Interstitial` or `Direct` | the page with the email button and the address field |
| off | `Interstitial` | the launch page every channel gets: it opens the email by itself and keeps a button in case it did not open |
| off | `Direct` | no page — the hop link opens the email at once |
With the emailed link on, the page never opens the mail client by itself: it holds an address field,
and a client that opened on its own would take the user away from it. After the tap the page says
"The confirmation email is open in your mail app. Send it to continue.", once the user is back from the
mail app, it asks them to wait for the confirmation, and when the sign-in ends it shows the outcome.
With the emailed link off, Email behaves like any other channel under hop mode: the launch page
opens the email by itself, and once the user taps its button it says the same line, then shows how the
sign-in ended. A user who comes back to the launch page from the mail app — or returns to it with
**Back** — sees "If you have already sent the email, wait for the confirmation. It usually takes a
few seconds." instead.
Where the sign-in window asks the question — the default for Email, see
[The page of the emailed link](#the-page-of-the-emailed-link) — the mail ties the mailbox to the
sign-in, and the question waits on the sign-in window. Both phone pages then say "Return to the
sign-in window and confirm the sign-in there.": the page with the mail button takes away the address
field and the mail button and keeps its countdown; the launch page takes away its instruction, its
button and the spare code and goes on to show how the sign-in ended. The mail statuses that follow no
longer replace the line. The phone pages of every other channel do the same when
`LoginConfirmation.Mode` is `OnWebPage`.
Under `OnWebPage` the chat does not stay silent either: once the sender is linked to a sign-in, a
channel that sends messages to the chat replies once with "Sign-in started — confirm it on the sign-in
page", in the recipient's language, so the user knows to check the sign-in window. It is sent once per
sign-in — starting again from the same link sends nothing more — and only for a sign-in, not for a
confirmation. Email sends no such email. The text is the message kind `web-confirmation-notice`, which
you can reword like any other message, per channel as well — see
[Veriqa:MessageTemplates](/docs/reference/configuration#veriqamessagetemplates). The outcome receipt follows
as before, and a "No" on the page sends nothing further to the chat.
The sign-in page on the computer follows the phone: the scan of the Email QR code shows "QR code
scanned. Continue on your phone to confirm by email.", and opening the email — by the tap, by the
automatic start of the launch page, or by the redirect itself where there is no page — shows "The
confirmation email is open in your mail app. Send it to continue."
### The emailed-link page
The link in a sign-in email (`PullEnabled`) opens a page of Veriqa, and what that page does depends on
where the sign-in is confirmed — the `LoginConfirmation.Mode` key of the
[configuration reference](/docs/reference/configuration):
* **The sign-in window asks** — the default for Email. The page asks nothing. Titled "Sign-in
request", it names the application — "A sign-in to `{app}` was requested. If it was not you, close
this page." — and offers **Continue signing in** and **Cancel**. "Continue signing in" passes the
sign-in on to the sign-in window: it uses up the link and ties the mailbox to the sign-in without
completing it, and the page turns into "Return to the sign-in window and confirm the sign-in
there." with a **Close** button. The one question of the sign-in — confirm or decline — is asked
once, on the sign-in window where the sign-in started: **Yes** signs the user in, and profile
details the application still lacks are collected right there; **No** declines the sign-in, and the
application gets `access_denied`. The same link opened again says it is invalid or has expired.
* **The sign-in window does not ask** (`None`). The link completes the sign-in when it is opened, and
the page shows how the sign-in ended.
**The browser that took part in the sign-in is not asked to press the button.** When the sign-in
holds the fingerprint and the IP address of the phone that scanned its hop QR code —
[hop mode](/docs/reference/configuration#veriqahopmode) with `HopMode.CollectFingerprint` on, and
`HopMode.CollectIpAddress` on (the default) — the page computes the fingerprint of the browser it is
open in and continues by itself when both match: the user sees "Return to the sign-in window…"
without a tap. Any mismatch, a browser that cannot compute the fingerprint, or a sign-in without
those values leaves the button on the page. The fingerprint sent by the page is compared and
dropped: it is neither stored nor written to the log.
"Cancel" and "Close" only close the tab and change nothing; when the browser keeps a tab it did not
open by script, the page says "You can close this tab." instead. Opening the page does not use up the
link: a mail gateway that follows it passes nothing on — only the POST of the button does. The link of a [confirmation](/docs/reference/confirmation-api) keeps its own page with
one "Continue" button.
### Sending an email again
After a link has been sent, the address field of the sign-in page stays editable and the button
turns into **"Send again"**. It stays disabled for `MailResendInterval` — 30 seconds by
default — and then comes back by itself; a request that arrives sooner is refused with `429` and sends nothing.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Email": {
"MailResendInterval": "00:00:30"
}
}
}
}
```
* The interval is counted **per sign-in, not per address**: correcting the address and pressing the
button within the interval is refused the same way.
* The sign-in email and the emails of the [claims completion](/docs/concepts/claim-enrichment) are counted
separately, so a sign-in by an emailed link does not delay the first completion email.
* `00:00:00` switches the interval off; a negative value prevents startup. The setting is node-wide.
* The interval is not the request limit and not the email limits of the claims completion
(`ClaimCompletionEmailLimits`): all three apply independently.
* An email that failed to go out still starts the interval — the next attempt waits it out.
The response to `POST /auth/email/start` carries `retryAfterSeconds` — how long to wait before the next
email — both on success and in the `429` refusal, which also sets the `Retry-After` header. A sign-in
page of your own can disable its button for that long.
### One-tap email over IMAP
With `Inbound:Provider` set to `ImapPolling` Veriqa reads the mailbox itself: no inbound provider,
no webhook secret and no public address of your server are needed — a mailbox and its password are
enough. A background service signs in to the mailbox over IMAP, takes the unread emails and hands
each of them to the same processing a webhook delivery gets, so the sender check and the result of
the sign-in are identical.
```json appsettings.json theme={null}
{
"InboundVerification": "outbound_fetch",
"PushEnabled": true,
"Inbound": {
"Provider": "ImapPolling",
"InboundAddress": "login@your-domain.com",
"UsePlusAddressing": true,
"VerificationPolicy": "DmarcAlignedPass",
"Imap": {
"Host": "imap.your-provider.com",
"Port": 993,
"Username": "login@your-domain.com",
"AuthMode": "Password",
"Password": "…",
"Folder": "INBOX",
"PollIntervalSeconds": 10,
"AuthservId": "mx.your-provider.com"
}
}
}
```
The keys of `Veriqa:Channels:Email:Inbound:Imap`:
| Key | Required | Default | Purpose |
| - | - | - | - |
| `Host` | yes | — | Name of the IMAP server |
| `Port` | no | `993` | Port, 1…65535. `993` means implicit TLS, any other port means STARTTLS |
| `Username` | yes | — | User name of the mailbox |
| `AuthMode` | no | `Password` | How Veriqa signs in to the mailbox: `Password` or `OAuth2` |
| `Password` | with `AuthMode: Password` | — | Password of the mailbox or its app password. A secret |
| `Folder` | no | `INBOX` | The folder Veriqa reads |
| `PollIntervalSeconds` | no | `10` | Upper bound of the pause between two visits to the mailbox, 1…3600 |
| `AuthservId` | with `DmarcAlignedPass` or `SpfOrDkimPass` | — | The `authserv-id` of the mail server of the mailbox — see below |
`InboundAddress` is required with this provider in every environment, not only in Production: it is
the address the user's email is sent to, so it has to be the address of this mailbox. With
`UsePlusAddressing` the email goes to `login+{token}@…` — check that your mail provider delivers
plus-addressed mail to the base mailbox.
**How it behaves.**
* An email reaches the sign-in — completes it, or ties the mailbox to it where the sign-in window
asks — no later than `PollIntervalSeconds` plus the time of one visit to the mailbox after it has
arrived.
* Only unread emails that arrived no earlier than `TokenTtl` ago are taken: an older email is
neither read nor marked. An email that was handled —
accepted or rejected — is marked as read. Nothing is ever deleted.
* The connection is TLS only and the server certificate is always validated; there is no switch
that turns the validation off.
* A failure of the mailbox or of the authorization server — network, wrong password, a refused
token — does not stop the application or the other channels. The error is logged without secrets
and the visit is retried after 1 second, then 2, 4 and so on up to 60 seconds; the first
successful visit resets the pause.
* The webhook route `/api/channels/email/inbound` answers `404` with this provider.
* In a multi-tenant installation every tenant with this provider is read by its own loop with its
own mailbox settings.
**Where `AuthservId` comes from.** SPF, DKIM and DMARC are checked by the mail server of the
mailbox, which writes the verdicts into the `Authentication-Results` header of the email
([RFC 8601](https://www.rfc-editor.org/rfc/rfc8601)). A sender can put a forged header of the same
name into an email, so Veriqa trusts only the header whose first word — the `authserv-id` — equals
`AuthservId`, and of several such headers the topmost one. When an email carries no header with
this identifier, the verdicts are empty and the policy rejects it. `AllowListOnly` does not read
the header, so it does not need the key.
To learn the value, open the source of any email this mailbox has received ("Show original" or a
similar command in your mail client) and look at the topmost `Authentication-Results` header:
* `Authentication-Results: mx.your-provider.com; spf=pass …; dkim=pass …; dmarc=pass …` — the value
opens with a host name followed by `;`. That name is the `AuthservId`.
* `Authentication-Results: spf=pass …; dkim=pass …; dmarc=pass …` — the value opens with a verdict
(a word with `=`), so the server states no `authserv-id`. There is nothing to put into
`AuthservId`: do not copy `spf=pass` there.
Compare the sources of several emails: `AuthservId` holds one value. A mailbox whose identifier
is missing or differs from email to email does not fit this mode — connect it through the webhook
or use a mailbox of another provider.
**A mailbox whose server writes the header without an `authserv-id` cannot be used with
`DmarcAlignedPass` or `SpfOrDkimPass`.** Veriqa has no way to tell the header of such a server
from one the sender wrote, so a header without an identifier is never trusted. Do not fill
`AuthservId` with a value the server of the mailbox does not write itself: an email with no
header carrying that value is rejected and marked as read, so honest sign-ins do not complete,
while a sender who writes a header with that value into the email gets it trusted — the sender
check then passes on verdicts the sender wrote. Microsoft documents this form of the header for Microsoft 365 — see
[Authentication-results message header](https://learn.microsoft.com/defender-office-365/message-headers-eop-mdo#authentication-results-message-header);
check an email in your own mailbox as described above. For such a mailbox either receive the
emails through the webhook of a mail provider (`Inbound:Provider: Webhook`), or read a mailbox
of another provider. `AllowListOnly` does start with such a mailbox, but it checks only the domain of
`From`, which a sender can forge — it does not replace the SPF, DKIM and DMARC check.
**Dedicate the mailbox — or at least the folder — to Veriqa.** Every unread email in `Folder`
that is recent enough to carry a live token gets marked as read, whoever sent it. Do not point
the service at a mailbox people read.
Limitations:
* `VerificationPolicy: ProviderVerified` cannot be combined with `ImapPolling` — a mailbox supplies
no provider-verified sender; the host does not start with this pair.
* A mismatch between `ImapPolling` and an `InboundVerification` other than `outbound_fetch` is a
`Warning` at startup, as with a messenger in `Polling` mode.
#### Mailbox sign-in with OAuth
Some mailboxes do not accept a password at all — Microsoft 365, and Google Workspace organizations
that turned password sign-in off. OAuth changes only how Veriqa signs in to the mailbox: the
sender of an email is still checked by `VerificationPolicy`, so first make sure the mailbox passes
the `AuthservId` check above. For such mailboxes set `AuthMode` to `OAuth2` and fill
`Veriqa:Channels:Email:Inbound:Imap:OAuth`. The section holds not an access token — that one is valid
for about an hour — but the long-lived data it is obtained with: Veriqa requests the token from
`TokenEndpoint` itself, keeps it in memory and requests a new one when it expires.
| Key | Required | Purpose |
| - | - | - |
| `TokenEndpoint` | yes | Token endpoint of the authorization server. An absolute `https` URL |
| `ClientId` | yes | Identifier of the application you registered with the authorization server |
| `ClientSecret` | when `RefreshToken` is empty | Secret of the application. A secret |
| `RefreshToken` | no | Set — the token is obtained with the refresh token; empty — with the application's credentials. A secret |
| `Scope` | when `RefreshToken` is empty | Requested permissions. With a refresh token it is sent only when set |
`Password` is ignored in this mode, and the `OAuth` section is ignored with `AuthMode: Password`.
The mailbox is signed in to as `Username` with the `OAUTHBEARER` mechanism when the server offers
it, with `XOAUTH2` otherwise.
Which of the two standard grants ([RFC 6749](https://www.rfc-editor.org/rfc/rfc6749)) is used
follows from `RefreshToken`:
* **With a refresh token** — the mailbox of a particular user, who gave the consent once. Choose it
when the mailbox is an ordinary account and the provider issues refresh tokens for it.
* **With the application's credentials** (`client_credentials`) — the application reads the
mailbox without any user. Choose it when the administrator of the organization can grant the
application access to the mailbox; there is no refresh token to renew.
```json With a refresh token theme={null}
{
"Imap": {
"Host": "imap.your-provider.com",
"Username": "login@your-domain.com",
"AuthMode": "OAuth2",
"AuthservId": "mx.your-provider.com",
"OAuth": {
"TokenEndpoint": "https://auth.your-provider.com/oauth2/token",
"ClientId": "YOUR_CLIENT_ID",
"ClientSecret": "…",
"RefreshToken": "…"
}
}
}
```
```json With the application's credentials theme={null}
{
"Imap": {
"Host": "imap.your-provider.com",
"Username": "login@your-domain.com",
"AuthMode": "OAuth2",
"AuthservId": "mx.your-provider.com",
"OAuth": {
"TokenEndpoint": "https://auth.your-provider.com/oauth2/token",
"ClientId": "YOUR_CLIENT_ID",
"ClientSecret": "…",
"Scope": "SCOPE_FROM_YOUR_PROVIDER_DOCS"
}
}
}
```
The addresses and the scopes above are placeholders. Registration of the application, the
permissions it needs and the actual values are described by the mail provider — take them from
its documentation, they change:
* **Microsoft 365** —
[Authenticate an IMAP, POP or SMTP connection using OAuth](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth):
the registration of the application, the scope for each of the two grants and, for the
credentials of the application, the steps an administrator performs to let it into a mailbox. The
token endpoint is described in
[OAuth 2.0 client credentials flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow)
and
[OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow).
* **Google** —
[OAuth 2.0 mechanism](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol) names
the scope of IMAP access, and
[Using OAuth 2.0 to Access Google APIs](https://developers.google.com/identity/protocols/oauth2)
describes the registration of the application, the token endpoint and how a refresh token is
obtained. Only the refresh token grant applies here: see the limitations below.
Limitations of the OAuth sign-in:
* **The consent is given outside Veriqa.** Veriqa has no consent screen and no redirect address
for this: the administrator obtains the refresh token following the mail provider's instructions
and puts it into the secrets.
* **A renewed refresh token is not written back.** An authorization server may return a new
refresh token together with an access token; Veriqa uses it until the process stops and never
writes it to configuration. With a provider whose refresh tokens expire, the administrator
replaces the value in the secrets.
* **A Google service account is not supported** — sign-in with a signed JWT
([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) is not implemented.
### Providers
The shipping providers are **`Smtp`** for outbound and **`Webhook`** and **`ImapPolling`** for
inbound. Both settings also accept **`Custom`**, in which case you register your own
`IEmailOutboundSender` /
`IEmailInboundProcessor` in DI before calling `AddEmail()`. Any other value fails fast at startup
rather than silently doing nothing.
A custom `IEmailOutboundSender` sends the sign-in email only, unless it also answers
`SupportsClaimCompletionEmail` with `true` and implements `SendClaimCompletionEmailAsync`: those two
members send the code and link emails that confirm an email address during the claims completion.
Without them the "Confirm via email" way of obtaining the email — the one `Code` and `MagicLink`
stand for — is unavailable, and the web step offers only the ways that remain.
**Multi-instance caveat.** Email magic-link tokens and Push correlation tokens are held
**in memory** by the shipping stores. Across several instances a link issued by one instance is
unknown to the others. Run Email on a single instance, route by sticky sessions, or register
distributed implementations of `IEmailActionTokenStore` and `IEmailPushCorrelationStore`. The
other channels are unaffected.
Your own `IEmailPushCorrelationStore` implementation must override `StoreOrReuseAsync` — the
idempotent registration of a token ("the transaction already has a live token — return it and
write nothing"). The interface's default implementation simply stores the supplied token, so
without an override Push mode issues a **new**
correlation token on every render of the sign-in page, and all of them stay alive until the TTL
expires or one is consumed. The capture of the "transaction → live token" index must be atomic
(compare-and-swap / `SET NX` / transaction), and the TTL of a reused token must not be extended.
### Your own mail body
The sign-in email is an ordinary **message** of the text mechanism: both the subject and the whole
HTML document of the body live as the value of the `Veriqa:MessageTemplates:sign-in-mail` key (how the
key is built — in the [Configuration reference](/docs/reference/configuration#veriqamessagetemplates)).
So the email is rewritten **by a setting, with no code involved**: set your own ladder of variants at
the level you need.
```json appsettings.json theme={null}
{
"Veriqa": {
"MessageTemplates": {
"sign-in-mail": {
"Templates": [
{
"Subject": "Sign in to {app}",
"Html": "
",
"Plain": "{text_intro}\n{open_link_hint}\n{link}"
}
]
}
}
}
}
```
What matters here:
* **the markup inside a variant is not escaped by the product** — it is written by whoever owns the
configuration. What is escaped are the **slot values**: they come from outside, an application over
the API included;
* **the visible phrases stay localization keys.** `{button}`, `{qr_instruction}`, `{qr_alt}` and their
kin are localized-text slots: the message contract states which key a slot looks at, and the
translation for the recipient's language is what gets substituted. The markup is therefore one text
for every language, and there is nothing to translate as copies. The shipped email has no phrase
outside such slots — `{heading}`, `{text_intro}`, `{request_from_label}`, `{button_hint}`,
`{valid_until_label}` and the rest; the two-variant example above writes its heading and the
"Request from" line into the markup, and those stay in the language you wrote them in;
* **what Veriqa computes is never shortened; what comes from outside is.** `{link}`, `{valid_until}`
and the other values the product computes itself are substituted in full. The application name, the
initiator details and a translated phrase are held to 128 characters — keep one phrase per slot
short, and split a long sentence into several slots;
* **the form of the QR is decided by the text, not by a setting.** A variant naming `{qr_cid}` gets the
PNG attachment; a variant naming `{qr_url}` instead (the link to the QR endpoint, if your composer
supplies it) gets the image by URL and carries no attachment; a variant naming neither is the email
without a QR. An attachment nothing points at never happens;
* **the email is one, and a step of the ladder has two editions.** A step is either a string (a text
that suits any sink) or an object `{ "Subject": …, "Html": …, "Plain": … }`. Each edition is
optional, but a step must specify at least one: a step specifying none fails startup, naming the message
kind and the number of the step. A step specifying only `Html` does not serve the plain-text part — the
selection simply moves on down the ladder, so **the two parts of one email may come from different
steps**. The subject is then taken from the step chosen for `Html`;
* **the last step of the ladder must rely on guaranteed slots only** (`app`, `valid_until`, `link`,
`lang`). The initiator details and the QR are not guaranteed: a transaction without them simply
reaches the step that names none of them — no empty paragraph in the email, and no literal
`{browser}`. This is counted **per edition**: the selection runs among the steps specifying the edition
asked for, so a ladder has two "last" steps — the last one with `Html` and the last one with
`Plain` — and the rule holds for both. A ladder whose last `Html` step names `{browser}`, with only
the `Plain` one made slot-free, fails startup;
* **an HTML-only email (or a text-only one) is a legitimate declaration.** When no step specifies `Plain`,
the email goes out without a text part: no error in the log, no substitution of one edition for the
other. Which is exactly why you should make sure a one-part email is your choice and not a forgotten
edition: in the data the two are indistinguishable.
**The ladder is a safety net under the data, not a substitute for settings.** It exists because the
initiator details (`{browser}`, `{os}`, `{region}`) and the QR may be missing at runtime — not
because wordings may be left unwritten. A declaration **replaces the whole ladder**: specify a single
step just to "reword one line" and you silently lose the entire ladder the core ships, and with it
every degraded variant. And the last step is no technical stub: its text is the one a user reads most
often, in exactly the conditions where the context is unavailable.
The core says both of these out loud **once at startup**, as a `Warning` record: when a configured ladder
is shorter than the one shipped at the same address (the message names the kind, the address and both
step counts), and when no step specifies `Plain`. Startup is not blocked — a short ladder and a
one-part email remain legitimate declarations. **What is checked is what startup can see:** the
deployment's section and the shipped file, that is, the `Core` level. Values set by a tenant, an
application or a `ui_config` record cannot be enumerated at startup — their absence from the warnings
does not mean all is well there.
If the email has to be built **in code** — pulling the markup from your CMS, supplying slot values of
your own, or deciding the subject yourself — one seam is left: the public `IEmailMessageComposer`. It
receives the data of the send and returns the finished body of the email (`EmailBodyContent`: the subject,
the HTML part, the plain-text part and the QR-attachment flag), while the delivery provider deals with
transport only.
```csharp Program.cs theme={null}
builder.Services.AddSingleton();
```
The registration follows the general DI substitution rule: an implementation of yours
registered before `AddVeriqa*` is already in the container and the shipped one is not added; after the
call — `RemoveAll` + `Add`.
The markup of the email is set by the text of a template variant in the configuration above (its
example opens this section); for full control, use `IEmailMessageComposer`. A composer of your
own takes over the **whole** body of the email: the degradation ladder, the localization of the visible
phrases through Natural Keys and the QR-attachment decision are then yours to implement.
### Address normalization
The `Normalization` section defines which form of an address Veriqa treats as the user identity:
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Email": {
"Normalization": {
"LowercaseDomain": true,
"LowercaseLocalPart": true,
"ProviderSpecificCanonicalization": false
}
}
}
}
}
```
`ProviderSpecificCanonicalization` enables provider-specific canonicalization. One family is
supported — Gmail (`gmail.com` and `googlemail.com`): dots in the local part are dropped, the `+`
tag is cut off, and `googlemail.com` is mapped to `gmail.com`. So `u.ser+shop@googlemail.com` and
`user@gmail.com` become the same user. Domains outside that list are never canonicalized — with many
providers the `+` is part of the address, and a blanket rule would merge different people.
**Turning this on in a live deployment changes identity.** Users who signed in before will get a
different `sub`: addresses that used to be distinct are merged into one. The core performs no
migration — this is an operational decision, which is exactly why the setting is off by default.
Decide it before your users' first sign-in, not after.
## Letting the client pick a channel
A client can restrict a given sign-in to specific channels through the standard OIDC `acr_values`
parameter, in `channel:{type}` form:
```
GET /connect/authorize
?client_id=my-app
&response_type=code
&scope=openid profile
&acr_values=channel:telegram
```
List several (`channel:telegram channel:whatsapp`) to narrow the choice without fixing it. Without
the parameter, every enabled channel is offered.
## Checklist
`AddXxx()` is called **and** the section has `"Enabled": true`.
Every channel section sets `InboundVerification`, and the value matches the mode the channel
actually runs in.
`https://…/api/channels/…` is publicly reachable over HTTPS and not blocked by your proxy.
No token or password sits in a committed configuration file.
A real confirmation from a phone completes the flow and issues tokens.
## Next steps
Every section, key and default in one place.
Sign-in page design, localization, extension points.
What to check before shipping.
How adapters fit the rest of the connector.
# Client compatibility
Source: https://veriqa.app/docs/guides/client-compatibility
Compatibility quirks: named relaxations of the request form, enabled for one client.
Some OIDC clients send requests in a shape that departs from the RFC. Veriqa is strict and rejects
them — the right default, except when you cannot fix the client: someone else's plugin, an
off-the-shelf CMS, a vendor build.
A **compatibility quirk** is a named relaxation of the request form, enabled **for a single
client**. Every quirk is narrow, off by default, and ships with a removal condition stated up
front: once the upstream defect is fixed, the key is retired along with its handler.
A quirk is a behavior branch, not a setting. Enable one only when a specific client genuinely
cannot send a conformant request, and retire it as soon as the removal condition is met. Veriqa
reports every enabled quirk with a startup warning in the log.
## Why per client, not globally
A relaxation enabled globally weakens the check for **every** application — including those that
send conformant requests, and those that will appear in the configuration tomorrow. Strictness
stops being a property of the deployment and becomes a property of its weakest participant.
So a quirk is addressed by `ClientId`: the client with the broken plugin gets the relaxation, every
other client stays strict. That also keeps the relaxation visible — the configuration says **which**
application is allowed **what** beyond the RFC.
## Enabling
Enabling takes two steps: listing the quirk on the client is not enough, the deployment gate has to
be open as well. While the gate is closed, the client's quirk set is intersected with the empty
core-level set and no relaxation applies.
```json appsettings.json theme={null}
{
"Veriqa": {
"OpenIddict": {
"CompatibilityQuirksAllowApplicationOverride": true,
"Clients": [
{
"ClientId": "my-wordpress-site",
"DisplayName": "My WordPress Site",
"AllowedRedirectUris": [ "https://example.com/oidc/callback" ],
"AllowedScopes": [ "openid", "profile", "email" ],
"CompatibilityQuirks": [ "drop-scope-on-code-exchange" ]
}
]
}
}
}
```
| Key | Level | Default | Meaning |
| - | - | - | - |
| `Veriqa:OpenIddict:CompatibilityQuirksAllowApplicationOverride` | deployment | `false` | The gate: while it is closed no quirk takes effect, whatever the clients list |
| `Veriqa:OpenIddict:Clients[].CompatibilityQuirks` | client | unset | Quirk keys from the closed registry |
| `Veriqa:OpenIddict:Clients[].CompatibilityProfile` | client | unset | A name from the closed registry: expands into quirk keys and is unioned with `CompatibilityQuirks` |
The two steps exist so that a single client's configuration cannot weaken the check on its own: the
overall ceiling belongs to whoever owns the deployment, the specific application to its client entry.
### A profile instead of a list of keys
Rather than writing the keys out one by one, you can name a profile — the engine and plugin you
actually run. A profile expands into the very same keys and passes the same validation, the same gate
and the same ceiling: it is a shorthand, not a second mechanism.
```json appsettings.json theme={null}
{
"ClientId": "my-wordpress-site",
"CompatibilityProfile": "wordpress-openid-connect-generic"
}
```
| Profile | What it enables |
| - | - |
| `wordpress-openid-connect-generic` | `drop-scope-on-code-exchange` |
| `moodle-auth-oidc` | `drop-resource-parameter` |
A profile and an explicit list can be combined — the keys are unioned. An unknown profile name fails
the start with the list of allowed names, exactly as an unknown quirk key does: a typo cannot quietly
leave the relaxation switched off.
A profile may carry **relaxations of the request form and nothing else**. Nothing that changes claims
can be enabled by one: switching on compatibility with a broken client must not also change what the
token says about who signed in.
## Disabling
Remove the key from that client's `CompatibilityQuirks` — the relaxation stops applying on the next
start. To drop every quirk in the deployment at once without touching client entries, close the
gate: `CompatibilityQuirksAllowApplicationOverride: false`.
Nothing needs disabling to begin with: **the default is off for everyone**. A fresh deployment
applies no quirk until you open the gate and list a key explicitly.
## What Veriqa reports at startup
* **A quirk is in effect** — a warning listing "client → enabled quirks". This noise is intended: an
enabled relaxation must not be silent.
* **A quirk is listed but not in effect** — a warning with the reason. If the gate is closed it says
so, and opening the gate is the fix. If the gate is open and the set is still empty, the gate is
not the cause: the client entry never reached the resolver, most often because a second entry with
the same `ClientId` shadows it.
* **An unknown key** — the start does **not** continue: the configuration picks a key from a closed
registry, and a typo here would mean a silently unapplied relaxation. The message lists the
allowed keys. The comparison is case-sensitive — `Drop-Scope-On-Code-Exchange` is rejected just
like a typo.
The configuration selects a **key**, never a type or an assembly name: no reflection is ever
performed on a configured string. The right to edit configuration does not grant code execution
inside the identity provider.
## Quirk registry
### `drop-scope-on-code-exchange`
**What it does.** Drops the `scope` parameter from a token request made with
`grant_type=authorization_code`.
**Why.** RFC 6749 §4.1.3 does not define `scope` in that request, and the strict check rejects it as
`invalid_request`. On a code exchange the submitted `scope` has no effect anyway — the granted set
comes from the authorization code — so dropping the parameter is equivalent to the client never
having sent it. No rights are widened.
**Who needs it.** The `openid-connect-generic` plugin for WordPress puts `scope` into the code
exchange request. The symptom: sign-in reaches the code exchange and fails with
```json theme={null}
{ "error": "invalid_request", "error_description": "The 'scope' parameter is not valid in this context." }
```
In WordPress that response is not what the integrator sees: the plugin returns the browser to the
site login page and puts the error text into the address —
`wp-login.php?login-error=invalid_request&message=The+'scope'+parameter+is+not+valid+in+this+context.`
With the quirk enabled the same request passes the form check, and the log gains an entry about the
dropped parameter — carrying the `ClientId` and the dropped value. No site-side change is needed:
sign-in works on the stock plugin, with no patch installed on the site.
**What is untouched.** The `grant_type=refresh_token` path: there `scope` is allowed by RFC 6749 §6
and **narrows** the granted rights. The quirk leaves that path alone — narrowing rights is both
legal and useful.
**Removal condition.** The upstream fix
[`oidc-wp/openid-connect-generic#497`](https://github.com/oidc-wp/openid-connect-generic/issues/497)
is released and rolled out by the integrators running the plugin. After that the key is retired
along with its handler. While the issue is open, the quirk is needed.
### `drop-resource-parameter`
**What it does.** Drops the `resource` parameter ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707))
from the authorization request and from the token request.
**Why.** Veriqa registers no resources, so the strict check rejects any value of that parameter with
`invalid_target` and the sign-in fails before it starts. Nothing here consumes the parameter: with no
registered resources the granted rights come from the scopes alone, so dropping it yields exactly the
token a client that never sent it would receive. The quirk grants no audience and narrows none — it
is **not** support for resource indicators.
**Who needs it.** The `auth_oidc` plugin for Moodle keeps the Microsoft Entra ID dialect it was
written for and sends `resource` on every request, in its generic mode too; there is no setting to
switch that off. The symptom: sign-in stops at the authorization request with
```json theme={null}
{ "error": "invalid_target", "error_description": "One of the specified 'resource' parameters is invalid." }
```
With the quirk enabled the same request passes the form check, and the log gains an entry about the
dropped parameter — carrying the `ClientId` and the dropped value.
**What is untouched.** The default. Without the key enabled for that specific `client_id`, `resource`
is rejected with `invalid_target`.
**Removal condition.** Moodle's `auth_oidc` stops sending `resource` in its generic (non-Entra) mode.
There is no upstream ticket for that.
## Next
Every key, client ones included.
What to check before shipping to production.
# Declare your own configuration keys
Source: https://veriqa.app/docs/guides/config-keys
An extension you ship declares its settings at the core, application and ui_config levels through the same declaration catalog the core itself uses.
Veriqa resolves a setting through one mechanism: a key is **declared** once — its name, value type,
the levels it lives at and the address on each of them — and every read goes through the one
resolver, which applies the level precedence and the gate rules of that declaration.
That mechanism is not reserved for the core. An extension you ship — a channel adapter of your own,
an audit sink, an external admissibility check, anything that arrives as a separate assembly —
declares **its own** keys the same way, on the same levels, with the same protective model. This
page is the minimal working path.
The declaration API lives in `Veriqa.Core.Configuration`. It is part of the MPL-2.0 core the host
already runs (it arrives with `Veriqa.Core.AuthServer` and with the channel adapter packages), not of the
MIT contracts package — so reference it from the assembly that declares the keys, or declare them in the host.
Everything on this page is public API.
## Application and `ui_config` levels are open to extensions
The readers of the level records — the one that fetches the OIDC client entry and the one that
fetches the `ui_config` record — are `internal`, and an extension does not need them. The owner of a
key needs an **address inside** the record, not the record itself: you state where in the record
your value sits, and the reader hands the record to the resolution without learning what was read
out of it.
Both records accept members nobody declared in advance:
* **Application level** — an entry of the `Veriqa:OpenIddict:Clients` array in the host
configuration. It is read **as a node, by path**: whatever member you write into the entry is
readable at the address your key declares, whether or not `OidcClientOptions` happens to carry a
property of that name.
* **`ui_config` level** — the dynamic record. Fields it does not know are round-tripped
through `[JsonExtensionData]`, so your field survives every read and write of the record.
What an extension **cannot** do is replace the way a level's record is fetched — put the client
entry in a store of its own, for instance. That is a decision of the deployment, not of an
extension: the resolution contract has one implementation, and the parts of it that are replaceable
are named explicitly at registration. Serving a level from another store is one of them, and it is
stated by the host that owns the store (see the package README of `Veriqa.Core.Configuration`), not
by a library that declares keys.
## 1. A catalog of your own
A catalog holds the declarations of **one owner** — there is no shared catalog for all extensions.
Fill it from the static initializer of your key class and expose it:
```csharp AcmeConfigKeys.cs theme={null}
using Veriqa.Core.Configuration;
public static class AcmeConfigKeys
{
// Initialized before the declarations that fill it — the order the initializers run in.
private static readonly ConfigKeyCatalog Declared = new();
/// Section of the host configuration the core level of these keys lives in.
public const string CoreSectionName = "Acme:Approvals";
public static ConfigKey MaxAttempts { get; } = Declared
.Of("Acme.Approvals.MaxAttempts")
.At(ConfigLevel.Core, CoreSectionName + ":MaxAttempts")
.At(ConfigLevel.Application, "AcmeMaxAttempts")
.At(ConfigLevel.UiConfig, "acme_max_attempts")
.Default(3)
.Declare();
public static ConfigKey WindowSeconds { get; } = Declared
.Of("Acme.Approvals.WindowSeconds")
.At(ConfigLevel.Core, CoreSectionName + ":WindowSeconds")
.At(ConfigLevel.Application, "AcmeWindowSeconds")
.Default(60)
.Declare();
// What the registrar of the deployment reads.
public static ConfigKeyCatalog Catalog => Declared;
}
```
`Of(name)` opens the chain for a value of any type, `Text(name)` for the text shape most settings
have, and `Declare()` closes it: it builds the key, puts the declaration into the catalog and hands
the key back. **A chain that is never closed declares nothing** — the key does not exist and no level
of it is bound.
### Addresses per level
| Form | What it means |
| - | - |
| `.At(ConfigLevel.Application, ConfigLevel.UiConfig)` | The address **follows from the name**: the dots become path separators, so `Acme.Approvals.MaxAttempts` is read at `Acme:Approvals:MaxAttempts` inside the record of each level. |
| `.At(ConfigLevel.UiConfig, "acme_max_attempts")` | The address is written **explicitly** — where the deployed form of the configuration does not match the name of the key. |
| `.AtIdentity(ConfigLevel.Tenant)` | The level has **no path**: it is addressed by the identity of the key alone. That is what a store keeping rows "level × owner × key × dimensions" needs — there is no document to walk into. |
One rule accounts for more mistakes than any other here: **only the core level is addressed from the root
of the host configuration.** Its record *is* the application configuration, so `Acme:Approvals:MaxAttempts`
is a full path there. Above the core, an address is relative to the record of its own level — the
client entry, the `ui_config` record — and a declaration therefore names no store. An address above
the core that starts at a section of the host configuration **stops startup** with a message naming
the key, the level and the address.
A key whose value is a **composite type** is read from the subtree at its address with strict
binding: a member the type does not carry — a leftover of an earlier version, a stray key, a
"comment" written as a field — makes the whole subtree unreadable, and the step is then treated
exactly like a value the domain rejected (the record is discarded above the core level; at the
core level a key declaring `FailStart` stops the host). It is the accepted price of a guarantee:
without it a misspelled element of a set arrives as a smaller set that looks perfectly valid. Keys
of a scalar value are unaffected, and so are members you add next to your key inside a record you
do not own — a client entry is not a subtree of yours.
## 2. Register the catalog from your own composition
```csharp AcmeServiceCollectionExtensions.cs theme={null}
public static IServiceCollection AddAcmeApprovals(
this IServiceCollection services,
IConfiguration configuration)
{
// Your keys travel with your composition — the one moment that works for an assembly
// no composition root can name.
services.AddVeriqaConfigKeyCatalogs(configuration, AcmeConfigKeys.Catalog);
// ... the rest of your registrations
return services;
}
```
Called from the host as one line beside the rest:
```csharp Program.cs theme={null}
builder.Services.AddVeriqaAuthServer(builder.Configuration);
builder.Services.AddAcmeApprovals(builder.Configuration);
```
Three properties of that call are worth stating outright, because they are what make composing
independent extensions safe:
* **It is idempotent, per catalog.** A catalog the container already holds is skipped **alone**. A
composition called twice, and two compositions naming one catalog, both leave exactly one
registration.
* **It suppresses nothing.** Your call registers what you name and skips what is already there — no
owner can knock out the catalogs of another, and none is registered twice.
* **Order does not matter.** Every registrar declares its keys *before* the first binding is
registered, so whether your composition ran before or after the core's does not decide whether a
binding finds its key.
The reader of catalogs comes **with** this registration rather than being expected of the host: a
catalog nobody reads declares nothing, and its keys would be absent from the schema.
`AddVeriqaConfigKeyCatalogs` takes any number of catalogs; `AddVeriqaConfigKeyCatalog` registers one.
The shipped code takes both paths — the shared channel adapter package
`Veriqa.Core.ChannelAdapter` states its three catalogs in one call, and each channel adapter states
its own at its own registration, so a deployment declares the credentials of the channels it added
and of no others.
## 3. Read the value
```csharp theme={null}
public sealed class AcmeApprovalGate(IConfigurationResolver resolver)
{
public async ValueTask MaxAttemptsAsync(
string? applicationId,
CancellationToken cancellationToken)
{
var resolved = await resolver.ResolveAsync(
AcmeConfigKeys.MaxAttempts,
ResolutionContext.Of(tenantId: null, applicationId, uiConfigSelector: null),
ConfigDimensionValues.None,
cancellationToken);
// resolved.SourceLevel says which level answered — useful in your own diagnostics.
return resolved.Value;
}
}
```
`IConfigurationResolver` is the only entry point to resolution and is asynchronous: a level served by an
external store is read asynchronously, and a synchronous wrapper over it would be sync-over-async.
`ResolutionContext.ForTenant(tenantId)` covers the tenant-only case; `ResolutionContext.Core` asks
the core level alone.
Resolving several keys served by the same record? Open a `ConfigResolutionScope` over the context and
pass **it** in place of the context — the record is then read **once** for everything resolved inside:
```csharp theme={null}
using var scope = new ConfigResolutionScope(ResolutionContext.ForTenant(tenantId));
var attempts = await resolver.ResolveAsync(
AcmeConfigKeys.MaxAttempts, scope, ConfigDimensionValues.None, cancellationToken);
var window = await resolver.ResolveAsync(
AcmeConfigKeys.WindowSeconds, scope, ConfigDimensionValues.None, cancellationToken);
```
## 4. What a declaration can state
Your keys get the same model the core's do, not a reduced one.
**Level-combining semantics.** Plain value semantics is the default — an upper level overrides a
lower one freely:
| Chain member | Semantics |
| - | - |
| *(nothing)* | **Value** — the upper level overrides the lower one freely. |
| `.Set(intersect)` | **Set** — the effective value is the intersection of the levels, so a lower level cannot widen what an upper one allows. |
| `.ProtectiveCeiling(stricter)` | **Protective ceiling** — downward only stricter; a relaxation needs a gate opened by an upper level. |
| `.GatedValue(ConfigLevel.Application)` | **Controlled override** — the listed levels override only where the level below opened a gate; the rest override freely. |
**Domain of the value and what a value outside it costs:**
```csharp theme={null}
public static ConfigKey MaxAttempts { get; } = Declared
.Of("Acme.Approvals.MaxAttempts")
.At(ConfigLevel.Core, CoreSectionName + ":MaxAttempts")
.Domain(new ConfigValueDomain(
admits: static value => value is > 0 and <= 10,
boundary: "1…10 attempts",
correctAt: "Acme:Approvals:MaxAttempts",
onRejected: ConfigValueRejectionPolicy.FailStart))
.Default(3)
.Declare();
```
`SkipStep` (the default) leaves the offending step unset and the resolution moves on to the level
below; `FailStart` fails startup at the core level and discards the record above it. The
`boundary` text is what an operator reads in the report, and `correctAt` is where they are told to
fix it. A value never enters either message. An enum-typed key gets a domain of its members even
without this call.
**Dimensions.** `.Identity("channel")` states the dimension without which the question loses its
meaning ("the setting of *which* channel") — it is addressed at every step of the fallback chain and
never drops out. `.Narrowing("theme", "surface")` states narrowing attributes, most significant
first; they drop out of the chain one by one from the least significant. `.Fallback(...)` replaces
the generated chain where a key tries only a subset of the combinations.
**The rest of the chain, in one list:**
| Member | What it states |
| - | - |
| `.Default(value)` | What the **core** level answers where its record states nothing — the value the product ships with. It is data, not a hook, so the schema and the snapshot report print it too. |
| `.Required()` | Some level must state a value. |
| `.Secret()` | The value is a secret: the schema records that a value is set, never the value itself. |
| `.Cached(ConfigCachePolicy.ExternalStore)` | Caching policy — a 30-second TTL for a level behind an external store; `ConfigCachePolicy.None`, or a `Ttl` of your own. |
| `.BlankIsUnstated()` | A level stating a blank text states nothing at all — a half-finished edit is not an override. Text keys only. |
| `.PresenceMeans(value)` | What a level answers by the mere existence of its record. |
| `.EnumTokens()` | The value is written as the tokens of an enum. |
| `.Parse(...)` | Manual reading of one step — for one value standing over several members of a record (a path and its SRI hash), or a step reading a different leaf than its neighbor. |
| `.Stated(...)` | Every address the level may state this setting at, for the walk of a configuration snapshot. Changes no resolution. |
| `.NotWalked()` | The walk of a snapshot cannot reproduce this value; the report names it as standing outside instead of reporting half of it. |
## 5. Check that the declaration was picked up
The startup report of the mechanism prints what the deployment declares. One `Information` line
names the whole schema:
```
Configuration schema declares 68 keys: Acme.Approvals.MaxAttempts, AuthPage.Instruction, ...
```
Set the mechanism's log level to `Debug` and every declaration is printed with its boundaries — the line for
the key above reads:
```
Configuration key Acme.Approvals.MaxAttempts (Value): levels UiConfig, Application, Core, gated none, dimensions none, cache none, domain 1…10 attempts.
```
Two discrepancies are named there as well: keys declared without a single extraction binding in this
deployment (`Information` — which parts a deployment composes is its own decision), and a `Warning`
per "key + level" pair where the key declares a level this deployment serves and no binding was
registered for it, so that level stays silently empty. A key that never appears in the report at all
was not registered — the usual cause is a chain left unclosed (no `Declare()`), or a composition
that never called `AddVeriqaConfigKeyCatalogs`.
## An example in the shipped code
`CorePageBrandingConfigKeys` and `LoginConfirmationConfigKeys` live in `Veriqa.Core.ChannelAdapter` —
an assembly that *cannot* reference the auth server, a reverse dependency — and they declare keys on
the application and `ui_config` levels whose records the auth server fetches. They register their
catalogs from the channel adapter package's own composition, exactly as §2 above describes.
## Where this path is needed
* **A channel adapter of your own** — its credentials, texts and limits per tenant. The
[channel adapter guide](/docs/guides/custom-channel-adapter) resolves settings through
`IConfigurationResolver`; this page is where the key it resolves comes from.
* **An audit sink, a metrics exporter, an outbound gateway** — anything shipped as a separate
assembly that an operator has to configure per application or per tenant.
* **Your own restriction policy engine.** Veriqa's own admissibility rules are an internal contract
and are not published as an extension point: an integrator who needs another policy engine — Rego,
Cedar, a DSL of their own — ships that check themselves, and it keeps **its** configuration on
**its** keys, declared exactly as above.
## Next steps
Every section and key of the product, and the table of declared keys.
The other half of the extension path — the channel SPI.
# Build your own channel adapter
Source: https://veriqa.app/docs/guides/custom-channel-adapter
Plug a channel Veriqa does not ship — KakaoTalk, Zalo, an internal messenger — into your host without forking the core.
Veriqa ships eight channels, but the channel layer is an open extension point. If your users live in
a messenger that is not on the list, you write an adapter against the MIT contracts package and
register it in your host. No fork of the server core, no patch, no pull request to wait for.
An adapter is one class and one registration call:
```csharp Program.cs theme={null}
adapters.AddChannel(
"acme-chat",
sp => new AcmeChatChannelAdapter(options, sp.GetRequiredService>()));
```
From that single call Veriqa registers the adapter, maps its webhook endpoint with the same
protections as the built-in channels, and shows the channel in the sign-in window.
A complete runnable example lives in `samples/dotnet/custom-channel` — an adapter library plus a
host that wires it up.
Not writing .NET, or running the standard Veriqa image you do not rebuild? Write the adapter as a
service of its own in any language and plug it in by configuration — see
[An HTTP channel adapter in any language](#an-http-channel-adapter-in-any-language).
## 1. The adapter project
Reference the contracts package and nothing else from Veriqa:
```xml Acme.Chat.Adapter.csproj theme={null}
```
`Veriqa.Core.Contracts` is MIT-licensed and *is* the free SPI. What the package contains, and which
neighbors by namespace stay in the MPL-2.0 core, is listed in the package's own README (also shown
on its NuGet page). The server core (MPL-2.0) is a dependency of the *host*, not of your adapter.
## 2. Implement `IChannelAdapter`
Three members carry the flow; the rest are outbound helpers your platform may or may not have.
### `ValidateWebhookAsync` — the security gate
Runs before your processing code — though not before everything: the request first passes through
the tenant demultiplexer, which answers **403** on an unknown tenant segment without calling your
adapter at all, and only then does Veriqa read the body and build the envelope. Return `false` and the request is answered with **403** and never
reaches your processing code; an exception thrown here is treated exactly like a rejection (logged,
answered **403**), so a broken signature parser can never surface as a `500`. Verify the platform's
signature here, in fixed time, and reject by default:
```csharp theme={null}
public Task> ValidateWebhookAsync(ChannelInboundRequest request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_options.WebhookSecret))
{
return Task.FromResult(Result.Success(false));
}
request.Headers.TryGetValue("X-Acme-Signature", out var signature);
signature ??= string.Empty;
var isValid = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(_options.WebhookSecret));
return Task.FromResult(Result.Success(isValid));
}
```
### Declare the inbound verification level of your channel — and document it
Your `ValidateWebhookAsync` returning success tells the core only that "a check ran and passed".
**What** the check was, the core does not know and cannot derive: it holds no closed list of
channels, and a statement the adapter makes about itself would guarantee nothing — the declaration
comes from whoever carries the risk: the integrator.
So your channel's section carries the same required `InboundVerification` key the shipped
channels do:
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"acme-chat": {
"InboundVerification": "shared_secret"
}
}
}
}
```
The allowed values are `signature` (the body is signed with a platform key), `shared_secret` (a secret
presented by the incoming request), `outbound_fetch` (there are no incoming requests, an outgoing
one fetches the events) and `none` (authenticity is not verified); the full description is in the
[configuration reference](/docs/reference/configuration).
**For a channel plugged in the way this guide shows, the key is required but not checked at
startup.** The check that refuses to start a host when an enabled channel has no value
walks the channels declared at core level — the very set the core builds its enabled channel list
from. An `AddChannel` call registers the adapter and its route, not a core-level declaration, so
such a channel is not in that set — until you register one yourself, which
[working per tenant](#working-per-tenant) has you do, and from that point the check covers your
channel like a shipped one. The value is still read by the same resolver and still reaches
the audit record, so a missing declaration shows up as an empty attribute in the audit log rather
than as a refusal to start.
**It is the adapter author's duty to document which value their channel yields** in every mode it
supports. The integrator picks the value from your documentation: they do not read your validation
themselves, and the core does not derive it. The example above says `shared_secret` because the
validation shown above compares a header with a configured secret; a channel that fetches its events
with an outgoing request of its own declares `outbound_fetch`.
### `ProcessInboundEventAsync` — the raw body in, an intent out
Both methods receive the **same envelope**, `ChannelInboundRequest`: the HTTP method, the raw
**bytes** of the body, the headers and the query parameters (names compared case-insensitively,
repeated values joined with `", "`). Bytes really are bytes: a signature is computed over exactly
those, with no decoding step standing between the platform and your HMAC. Veriqa does not interpret
the body format — JSON, form-encoded, XML, whatever your platform sends; parsing it with your own
serializer options is your job.
```csharp theme={null}
public Task> ProcessInboundEventAsync(ChannelInboundRequest request, CancellationToken cancellationToken = default)
{
var payload = JsonSerializer.Deserialize(request.Body.Span, PayloadJsonOptions);
var snapshot = new ChannelIdentitySnapshot
{
ChannelType = "acme-chat",
IsBot = false,
ChannelUserId = payload.UserId,
DisplayName = payload.DisplayName,
CapturedAt = DateTimeOffset.UtcNow,
AdapterVersion = "1.0.0"
};
// The transaction identifier arrives as a string in the channel event, so the adapter is what
// parses it: the result field is typed, and an empty identifier cannot reach it by construction.
// Does not parse — the event is not ours: ChannelUnrelatedResult, never an "empty id".
if (!TransactionId.TryParse(payload.TransactionId, out var transactionId))
{
return Task.FromResult(Result.Success(new ChannelUnrelatedResult()));
}
return Task.FromResult(Result.Success(
new ChannelAuthStartResult { TransactionId = transactionId, Identity = snapshot }));
}
```
The intent is the **type** of the result, and each one brings its own mandatory set of fields:
`ChannelAuthStartResult` (the user reached your platform from the deep link),
`ChannelAuthStartByCodeResult` (they sent the code shown on the sign-in page — see
[Start by a code](#start-by-a-code-—-when-the-link-cannot-be-relied-on)),
`ChannelAuthConfirmResult` / `ChannelAuthDeclineResult` (they pressed a button in your channel),
`ChannelPhoneSharedResult` / `ChannelPhoneStepActionResult` (answers to a
[phone number request](#phone-number-on-request)), `ChannelUnaddressedResult` or
`ChannelUnrelatedResult`.
The last two are close and easy to confuse. `ChannelUnaddressedResult` is a **direct message to your
bot from a sender you have already vetted** — a bare start command, free text — that names no
transaction; it carries the sender, so Veriqa can answer it when the registration declares a reply
(`UnaddressedReplyText` below). `ChannelUnrelatedResult` is everything Veriqa has no business
answering: a group chat, a callback of somebody else's button, an event that failed your own sender
check. It carries no sender, and nothing is sent back.
Next to it sits a synchronous `bool OwnsInboundEvent(ChannelInboundRequest request)` — a cheap
answer to "is this a Veriqa update?" **before** anything is parsed. You need it when you own the
channel transport and hand updates out between your own bot and Veriqa: no event gets parsed twice
(this and the other ways to share a bot — [You already have a bot](/docs/guides/existing-bot)).
A default implementation is already provided — it looks for any `CallbackDataPrefixes` marker (`vq_confirm_`,
`vq_decline_`, `vq_phone_skip_`, `vq_phone_cancel_`, `vq_menu_`, `auth_`) in the raw bytes of the
body — so implementing the member is optional. `vq_menu_` marks a button of the
[post-completion menu](/docs/reference/configuration#veriqacompletionbehavior): what follows it is a value
from the deployment's configuration, not a transaction identifier. The
answer is one-sided: an event from a Veriqa button or deep link always answers `true`, but `true`
on its own does not promise the event is actionable — the search is substring-based and `auth_`
carries no vendor prefix, so the word `oauth_` in a user's message answers `true` as well. Override
the member with a precise check of your own wire format if a false positive costs you something.
### `Capabilities` — what your channel can do
A channel declares what it can do as a **fact**, not as a stubbed-out operation: one
`ChannelCapabilities` object states whether the channel can confirm a sign-in **inside itself**
(`SupportsInChannelConfirmation`), which content kinds it accepts, whether it can update its own
message (`SupportsMessageUpdate`), whether it shows the user where a transaction stands
(`DeliversOutcomeNotice`), whether it supplies the recipient's locale (`ProvidesRecipientLocale`)
and whether it renders emoji (`RendersEmoji`). Veriqa never calls what a channel has not declared —
so "cannot" is written once, in one place:
About confirmation you state exactly one fact: your own ability to ask the question and receive the
answer inside the channel. How you do that (inline buttons, a web view inside the messenger,
anything) is your business — the core neither knows nor asks. An adapter never names a surface of
the **core**: whether a confirmation is needed at all is the core's policy, and the page that asks
the question when the channel cannot is the core's own. The fact defaults to "cannot": an already
compiled adapter that knows nothing about it will not be handed a confirmation
it cannot display.
```csharp theme={null}
public ChannelCapabilities Capabilities => new()
{
// false — cannot confirm inside the channel; the core's page asks the question.
// Leaving the property out says the same thing: this is its default value.
SupportsInChannelConfirmation = false,
SupportedMessageKinds = new[] { ChannelMessageKind.PlainText }.ToFrozenSet(),
SupportsMessageUpdate = false,
DeliversOutcomeNotice = false,
ProvidesRecipientLocale = false,
// null — say nothing, and the core words the receipt as it does for a platform that is not
// guaranteed to render emoji. true — your channel renders them, and the terminal text is
// worded the way it is for a messenger.
RendersEmoji = null
};
```
`RendersEmoji` is the one three-state fact of the object: it decides which of the two in-channel
wordings a terminal receipt is addressed with, and that is the **channel's** rule rather than the
render point's. Leaving it out says "no rule of my own" — the core then answers from its own list of
the messengers it ships an adapter for, which names no third-party channel.
### Outbound operations
`SendMessageAsync(ChannelMessage)` delivers a message and returns a reference to what was sent
(`ChannelMessageRef`) — or `null` in a success when the channel has no addressable reference.
`SendConfirmationPromptAsync` sends the confirmation prompt and returns the same kind of reference;
Veriqa stores it, not you. `ReportOutcomeAsync(TransactionOutcomeNotice)` states the intent "show
the user where this transaction stands" — which platform calls that takes is your decision,
and it is called only if your channel declared `DeliversOutcomeNotice`. The notice also carries
`DisplayIntent` — where the deployment **wants** the receipt to appear: in place of the message that
asked the question (`ReplacePrompt`, the shipped value) or as a message of its own (`NewMessage`).
Honoring it is optional: your platform may have nothing editable to replace, and showing the outcome
the only way it allows is not a failure — nothing is logged as an error and the result you return
does not change.
The notice is terminal in every case but one. `Outcome` is `confirmed`, `declined`, `expired` or
`failed` when the transaction has ended, and `awaiting_claims` when the sign-in is confirmed and
waits for the details your application still asks for; `IsTerminal` tells the two apart. Show that
state the way you show a receipt: answer the press, put `StatusText` where the question was. A
sign-in that then ends confirmed, declined or failed is reported again, always as a new message.
The state is reported for the press that confirmed the sign-in. A press made later, while the
sign-in is still waiting, brings the same notice only to a channel that declared
`SupportsMessageUpdate`. A channel that cannot edit its messages is not called for it: the state
would arrive as a new message under the request of the step in progress and could take that
request's buttons away. Instead, while the sign-in waits for the phone number, such a channel is sent
the phone number request once more: on many platforms the press itself lands in the chat as a
message from the user and removes the buttons of the request, and the repeated request brings them
back. If your platform expects an answer to every button press, give that answer yourself when you
receive the event.
An expiry is reported for every transaction started from your channel, whether the question was
asked in the chat or not. `PromptMessage` is set only when the question went into the chat, your
channel declared `SupportsMessageUpdate` and returned a reference from the prompt. Without it —
the question was asked on the web page, the sign-in needed no question, or there was no reference
to keep — show `StatusText` as a new message. The only expiry that goes unreported is one whose
record Veriqa no longer has: by default it is kept in the memory of the instance that handled the
start ([channel stores](/docs/guides/storage#channel-stores-multiple-replicas)).
Honoring `NewMessage` is **two** steps rather than one, and the second is the one easy to miss:
deliver the receipt as a new message **and** take the buttons off the message that asked the question,
leaving its wording alone. Skip it and the user is left looking at a settled transaction with a live
button under it — a question that answers nothing and still invites a press. Take the buttons away
with whatever operation changes a message's buttons without rewriting its text; if your platform has
no such operation, leave the question untouched rather than blanking its text — an unhonored intent
is not a failure, a question that lost its wording is one. Whether the buttons came off decides
nothing about the result you return: the delivery of the receipt does.
### Phone number on request
A channel that can give the user's phone number declares it in `Capabilities.PhoneNumber`: how it
gets the number and whether the platform proves that the number belongs to the sender. What the
core does with the number — when it asks, how long it waits, what the token carries — is described
in [Phone number](/docs/guides/phone-number).
```csharp theme={null}
public ChannelCapabilities Capabilities => new()
{
SupportedMessageKinds = new[]
{
ChannelMessageKind.PlainText,
ChannelMessageKind.PhoneNumberRequest
}.ToFrozenSet(),
PhoneNumber = new PhoneNumberCapability
{
// Automatic — the number comes with the user's messages; OnRequest — the channel asks for it.
// Both may be declared at once.
Acquisition = PhoneNumberAcquisition.OnRequest,
// Proven only when the platform itself binds the number to the account that sent it.
Proof = PhoneNumberProof.Proven
}
};
```
Leaving `PhoneNumber` out means `PhoneNumberCapability.None`: the channel gives no number, and a
sign-in that requires one does not offer the channel.
With `Automatic` you put the number, in E.164, into the snapshot's `PhoneNumber` and say whether it
is proven in `PhoneNumberVerified` (left `null`, the claim goes out as `false`).
With `OnRequest` the core calls `SendMessageAsync` with a message of kind `PhoneNumberRequest`:
`Text` is the request, and `PhoneNumberRequest` carries the label of the share button
(`ShareButtonLabel`), the second action (`SecondaryAction` — `Skip` or `CancelSignIn`) with its label
(`SecondaryButtonLabel`) and the `TransactionId`. The texts arrive localized. Show the platform's
"share contact" button and the second button next to it: a button with a payload carries
`CallbackDataPrefixes.PhoneSkip` or `CallbackDataPrefixes.PhoneCancel` followed by the transaction
identifier, and a keyboard button that can only send text sends its label. Return the answers as:
* a shared contact — `ChannelPhoneSharedResult` with the number in E.164 with the leading plus and
the sender's snapshot. Normalizing the number to E.164 is the adapter's job: platforms rarely promise
that format, and the core counts a number outside it as absent. `E164PhoneNumber.TryNormalize` from
`Veriqa.Core.Contracts` does that. When the contact fails your platform's
ownership check, set `OwnershipRejected = true`: the core does not take the number and asks again.
A contact names no transaction — the core finds the request by the chat;
* the second button with a payload — `ChannelPhoneStepActionResult` with the parsed `TransactionId`
and `Action`; an identifier that does not parse makes the event `ChannelUnrelatedResult`;
* the second button as plain text — `ChannelUnaddressedResult` with `Text`: the core compares it with
the label it sent into that chat.
When the sign-in ends — by its outcome or by expiring — the terminal notice of `ReportOutcomeAsync`
carries `PhoneRequestMessage` —
the reference your `SendMessageAsync` returned for the request. Take down what the request offered,
its buttons or its input keyboard, by the means of your platform. The reference is absent when you
returned none, and a notice of `awaiting_claims` never carries it: the step is still running.
If you route updates with `OwnsInboundEvent`, override it: the default recognizes the button
markers, but a shared contact carries none. Answer `true` for a contact event and keep the marker
check with `CallbackDataPrefixes.ContainsAnyMarker(request.Body.Span)` — the default body of an
interface member cannot be called from your class. A button that sends plain text carries no marker
at all, so in this mode its press reaches your own bot: an `Optional` number is then skipped when
its window runs out, and a `Required` one waits until the transaction expires. The built-in Telegram
adapter has the same limitation.
### Claim names — which are yours and which belong to the core
Everything you know about the user goes into the **typed fields** of the snapshot: name, username,
locale, phone and whether it is proven (`PhoneNumberVerified`), email and whether it is verified
(`EmailVerified`) — each has its own field. The core issues the claims from them; `email_verified`
goes out only next to a non-empty email, `phone_number_verified` only next to a non-empty phone
number. Free-form
names live in `AdditionalClaims`, and there the three-group discipline applies (registry
`VeriqaClaimTypes`):
| Group | Names | What happens |
| - | - | - |
| Mandatory | `channel_type`, `channel_user_id`, `name` | always issued by the core; never taken from `AdditionalClaims` |
| Reserved for the core | `given_name`, `family_name`, `preferred_username`, `locale`, `phone_number`, `phone_number_verified`, `email`, `email_verified`, `picture`, `is_bot` | derived from snapshot fields; never taken from `AdditionalClaims` |
| Adapter namespace | any name prefixed with `{ChannelType}_` — for example `acme-chat_workspace` | accepted as is |
A key that breaks the rule is **dropped with a warning in the log** — authentication does not fail.
So you cannot claim `email` or `locale` with a string of your own: put the value in the snapshot
field. Names are compared **case-insensitively** — `Email_Verified` is the same name as
`email_verified`, so re-casing a letter does not get around the reservation. Prefixing with your own
channel type makes collisions between adapters impossible.
Letter case decides **whose** name it is, but grants no freedom in writing it: a claim of your own is
taken only in the declared spelling. With `ChannelType = "acme-chat"` the key `ACME-CHAT_workspace`
is dropped — write the prefix the way the channel type is written (`veriqa_channel` likewise, as the
registry spells it). What follows the prefix is yours: `acme-chat_Workspace` goes through. But two
keys of your own differing only in letter case are **one** claim: one of them is taken, the other
goes to the log as a warning. Which spelling survives is **not defined**: the set reaches the check
frozen, and its enumeration follows the layout rather than the order you wrote it in. Send one
spelling — a case-variant pair means losing one of the values.
### Error codes — from the declared registry, not strings of your own
You return a failure as `Result.Failure(code, message)`, and `code` comes from
`VeriqaErrorCodes` — the code registry declared in the same MIT package. A string of your own looks
like it works, but the core does not understand it: it branches on declared codes and tags
telemetry with them, while an unknown code stays a line in a log. A body in an unrecognized format is
`UnsupportedEventType`; a body without the identity fields is `IdentityExtractionFailed`. The meaning
of each code is in the XML documentation of `VeriqaErrorCodes`.
One code stands apart: `ChannelCannotContinue` — "I cannot carry **this** confirmation out". Return
it from `SendConfirmationPromptAsync` when your channel physically cannot ask the question for this
transaction, and the core **ends the transaction in failure** with that same reason code instead of
waiting for its TTL. The user sees the error in the browser right away, and
nothing reaches them in the channel. Any other failure code means "it did not work right now": the
transaction stays waiting for a retry or its TTL. You report no reason and do not pick what happens
next — you declare your own inability, the core decides.
### `GetDeepLinkAsync` — where the button and the QR code lead
The link you return is where your channel takes the user. The channel button always opens it as is.
The QR code carries it too — unless [hop mode](/docs/reference/configuration#veriqahopmode) is on for your
channel (it applies only to a channel [declared in the host](#6-register-it-in-the-host)): then the QR code carries a short one-time hop link to your Veriqa server, and opening it on
the phone leads to the same link your method returned. Nothing in your adapter changes for hop mode —
the core wraps the link above your contract.
Carry the transaction id in the link so your platform can post it back on the webhook:
```csharp theme={null}
public Task> GetDeepLinkAsync(TransactionId transactionId, CancellationToken cancellationToken = default)
=> Task.FromResult(Result.Success($"https://chat.acme.example/start?tx={Uri.EscapeDataString(transactionId)}"));
```
**Keep the call idempotent and free of side effects.** A repeated call for the same transaction
returns the same target and creates no state: it issues no new secrets, writes nothing to a store and
publishes no events. The core calls the method every time it shows your channel — on the sign-in
page, on the page where the user of a confirmation picks a channel, in the response of the
[server-to-server confirmation API](/docs/reference/confirmation-api) — and, in hop mode, again every time
a person opens the hop link. A method that, say, mints a fresh one-time code on each call turns every page
refresh and every scan into another live secret. The core cannot check this in your implementation —
the promise is yours to keep.
### Start by a code — when the link cannot be relied on
Two kinds of platform need more than a link. One has no link that both opens the chat with the bot
and carries a payload, so the transaction cannot ride in it at all. The other has such a link but
does not always deliver what it carries. A channel on either declares a **start by code**: the core
gives the transaction a short code of decimal digits, shows it on its own pages, the user sends it
to your bot as a message, and you hand it to the core.
```csharp theme={null}
public ChannelCapabilities Capabilities => new()
{
// … the facts above …
CodeStart = new ChannelCodeStart
{
// The message your bot expects, {code} standing for the code exactly once.
// null — the user sends the code alone.
MessageTemplate = "/veriqa {code}",
// false — the link only opens the chat, and the code is the only way to start.
// true — the link carries the transaction, and the code is a fallback beside it.
LinkCarriesTransaction = false
}
};
```
`LinkCarriesTransaction` is a fact about **your link**, and the core decides the rest from it:
| | `false` — the code is the only start | `true` — the code is a fallback |
| - | - | - |
| `GetDeepLinkAsync` returns | a link that only **opens the chat** and carries no transaction | the link carrying the transaction, as without the declaration |
| The core's pages show | in place of the channel button — the message to send, always visible, and a button that copies it and opens your link | the usual button and QR code, and under them a block with the message and a button that copies it. The block appears when it may be needed — on a computer or a tablet 8 seconds after the page loads, on a phone behind a **Problem signing in** link once the user has tapped your channel's button and come back — and only while your channel has not answered: once the start of the sign-in arrived through the channel and the question went into the chat, the sign-in window hides the block and does not offer it again. The integrator switches the block off with `SpareStartCodeEnabled: false` in the [section of the channel](/docs/guides/channels#settings-every-chat-channel-shares) |
| The QR code | carries a hop link to your Veriqa server **whether or not** [hop mode](/docs/reference/configuration#veriqahopmode) is switched on; opening it on the phone shows a page with the same message and button, with no automatic launch — `HopRedirectStyle: Direct` does not apply | as for any channel: hop mode applies as configured. Once the user has opened your channel on the phone, the sign-in window shows the block under its instruction 6 seconds later, and the launch page of the `Interstitial` redirect style offers the code behind a **Problem signing in** link, with one button that copies it and opens your link. With a hop QR code on the page the 8 seconds above do not apply to your channel: the block waits until the phone opens your channel |
| [Server-to-server confirmation API](/docs/reference/confirmation-api) | the channel is left out of `channel_entries` and is never the `deep_link` entry: the code is not part of that contract, and the link is useless without it. The `page_url` entry leads to the page that shows the code | returned as before |
The two conditions of a hop link stay in force for the first column: the channel is
[declared in the host](#6-register-it-in-the-host), and the issuer is an absolute HTTPS URL. Where
either is missing, the QR code carries your link — it opens the chat, and the user reads the code off
the sign-in page. A host where such a channel is declared and enabled maps the hop route even with
hop mode off.
Whatever the user sent comes back to you on the webhook. Strip your command and hand over the rest:
```csharp theme={null}
return Task.FromResult(Result.Success(
new ChannelAuthStartByCodeResult { Code = code, Identity = snapshot }));
```
The core finds the transaction by the code and carries on exactly as after `ChannelAuthStartResult`.
What belongs to whom:
* **The format of the message is yours.** The core substitutes the code into `MessageTemplate` and
shows the result; it does not interpret the wording. A template that is blank, longer than 64
characters (`ChannelCodeStart.MaxMessageTemplateLength`) or does not hold `{code}` exactly once
cannot be used: a channel with `LinkCarriesTransaction = false` is then left out of the page, like
a channel that failed to build its link, and a channel with `true` is shown without the block.
* **The code is the core's.** Do not normalize it and do not check its length: the page shows it in
groups of three digits, the user may send it with spaces or dashes, and the core drops both. The
length is one setting of the installation, `Veriqa:CodeStart:CodeLength` — an integer from 6 to 10,
6 by default; a value outside the range stops the host at startup. One code serves the transaction
on every channel that declares the start, works once and lives exactly as long as the transaction.
* **The attempt limit is the core's.** An event with a code is charged to the same per-user
budget as a start by the link, before the code is looked up. A code that names no live
transaction — unknown, expired, already used — is counted like a start whose transaction cannot be
served and answered with the text the registration declares for it, `UnmatchedCodeReplyText`: one
text for every reason, so the answer does not tell whether the code ever existed. A registration
that declares none answers with `StaleLinkReplyText`, or with silence when it declares neither.
* **A message with a code carries no Veriqa marker**, so the default `OwnsInboundEvent` does not
claim it. Override the member if you route updates by it.
A code is shared by the whole installation: it is unique among all live codes, whichever tenant
the sign-in belongs to, and it is found whichever webhook address the message arrives at — with
the tenant's segment or without it. All tenants draw from one space of codes, so an installation
with many sign-ins in progress at the same time should raise `Veriqa:CodeStart:CodeLength`.
Of the shipped channels, Instagram and Messenger declare the fallback: their link carries the
transaction, and the platform does not always deliver it. The
[HTTP channel](#an-http-channel-adapter-in-any-language) declares the start by configuration — see
[The start by a code](#the-start-by-a-code).
Both modes can be tried in `samples/dotnet/custom-channel`: its README walks through the start by
code (`AcmeChat:StartByCode`) and the fallback (`AcmeChat:CodeFallback`).
## 3. Name your channel
The channel type is a contract, not a display string. It must match:
```
\A[a-z][a-z0-9-]{0,63}\z
```
A lowercase URL-safe token starting with a Latin letter — `kakaotalk`, `acme-chat`, `zalo`. The
value is matched ordinally against `acr_values` and becomes a URL path segment, so anything else
(`KakaoTalk`, `my_channel`, an empty string, a value with a trailing newline read from configuration)
**fails the startup** with a message naming the allowed format. The same fail-fast applies to a
duplicate registration of a type — which covers a type a shipped channel already claimed, because
the shipped channels are registered through this very `AddChannel` and live in the same registry —
and to the type passed to `AddChannel` differing from the one your adapter returns from
`ChannelType` — the two must be equal,
because everything downstream matches the channel by the adapter's `ChannelType`. That last check
runs on host start, not on the webhook mapping, so an outbound/polling-only channel registered with
`mapWebhook: false` is diagnosed just as strictly.
That last check has one boundary: a cross-wired swap — one channel's adapter registered under
another channel's type — is caught by cross-checking the adapters' runtime types. If your host
layers a sweeping decorator over **every** `IChannelAdapter` (Scrutor
`Decorate()` or a Castle proxy over the whole set), every adapter shares the
decorator's single runtime type and the wrapped types are hidden behind it: such a swap becomes
indistinguishable from a legitimate decorator and is **not** rejected — swap detection is
best-effort in that configuration. It stays exact for native adapters and for per-adapter class
proxies. The plain case — the type passed to `AddChannel` is declared by no adapter at all — is
always rejected, decorators or not.
## 4. The webhook endpoint
Registering a channel maps
```
POST /api/channels/{channelType}/webhook
POST /api/channels/{channelType}/webhook/{tenant}
```
with **the same rate-limit policy and the same 1 MB body limit** as the built-in channel routes —
the generic route is not a way around the protections applied to `telegram` or `whatsapp`.
Answer semantics:
| Situation | Answer |
| - | - |
| Unknown tenant segment in the path | `403`, before your adapter is called |
| Body over the 1 MB limit — the read aborts before validation | `200` + a log entry |
| `ValidateWebhookAsync` returns false / fails / throws | `403` |
| Channel not registered | `404` |
| `GET` on the generic path — the channel declared no verification handshake | `405` |
| Everything after a successful validation | `200` |
That last row is absolute: a `Failure` result and even an unhandled exception from
your adapter are logged and still answered `200` with an empty body. The rule is the same for every
channel: the routes of the shipped channels are served by the same handler, so an adapter exception
does not turn into a `500` there either. Messaging platforms disable webhooks or storm retries on
non-2xx, and the response body never leaks error details.
The 1 MB limit belongs to the request body: Veriqa reads the body itself, once and entirely into
memory, while building the envelope — after the tenant demultiplexer and before validation. An
over-limit body aborts that read as it arrives, without being read to the end; a failed read is not
a rejection verdict, so the answer is `200` plus a `Warning` log entry, **the same on the generic
route and on the built-in ones**. The over-limit update is lost, but the platform is not pushed into
endless redelivery; the symptom is the log entry, not the status code.
If your channel is outbound-only or polls the platform itself, register it without a route:
```csharp theme={null}
adapters.AddChannel("acme-chat", mapWebhook: false);
```
### Registration settings
`mapWebhook` is the one setting the short overloads spell out. Everything a registration can state
lives in `ChannelRegistrationOptions`, which both overloads accept in place of the flag — the
adapter type and the factory form alike:
| Property | Default | What it states |
| - | - | - |
| `MapWebhook` | `true` | Whether Veriqa maps the generic webhook route for the channel. `false` — an outbound/polling-only channel, or one that maps an inbound route of its own through `IChannelEndpointRegistrar`. |
| `ErrorMessageText` | `"Something went wrong. Go back to the sign-in page and start again."` | The Natural Key of the status text shown to the user when processing fails. It is the one status text a registration states: a terminal transaction outcome is a message of the mechanism and is resolved by address, while an error your channel reports is not an outcome of the transaction at all. |
| `StaleLinkReplyText` | `null` | The Natural Key of the reply to a sign-in start whose transaction cannot be served — it is gone, its window has closed, or (where the core web page asks the question) a decision on it is already recorded. `null` — your channel stays silent. A start on a sign-in that is confirmed and waits for the details the application still asks for gets this reply only on a channel that declares `SupportsMessageUpdate`: elsewhere a new message would take away the buttons of the request in progress, so the reply is not sent — and while the sign-in waits for the phone number, the phone number request is sent once more instead. One text covers every reason: the wording does not tell "never existed" from "expired". The reply goes out under the same per-user budget as the sign-in start itself, charged once per event. |
| `UnmatchedCodeReplyText` | `null` | The Natural Key of the reply to a [start by a code](#start-by-a-code-—-when-the-link-cannot-be-relied-on) that no live transaction answers to — unknown, expired, already used. One text covers every reason; word it so that it tells the user what to do: check the code on the sign-in page and send it again, or open the page again. `null` — the reply is `StaleLinkReplyText`, so a channel that stays silent on a stale link stays silent on such a code. Declare it when your channel starts by a code: a text about an expired link confuses a user who followed none. |
| `UnaddressedReplyText` | `null` | The Natural Key of the reply to a direct message that names no transaction (`ChannelUnaddressedResult`). `null` — no reply. |
| `WebhookVerificationQueryKey` | `null` | The query parameter carrying the challenge of the platform's verification handshake (below). `null` — the platform has none, and no GET route is mapped. |
An empty or blank string means the same as `null`: the channel stays silent.
The shipped channels take this very path: their status wording and their handshake are declared
here as well.
Telegram declares a `StaleLinkReplyText`, WhatsApp does not: a message outside an approved
template is chargeable there, so answering every stale link would cost the installation money. For a
channel of your own, decide by its platform's pricing. `UnaddressedReplyText` is declared by
Instagram and Messenger: a first tap there may arrive without the transaction, and the reply tells
the user to send the code shown on the sign-in page or to scan the QR code again. Their adapters
hand over only a tapped button as `ChannelUnaddressedResult` — a typed message that concerns no
sign-in is `ChannelUnrelatedResult` and gets no answer — and each has a setting that switches the
reply off ([Instagram](/docs/guides/channels#instagram), [Messenger](/docs/guides/channels#messenger)). The
other shipped channels leave it `null` and send nothing. `UnmatchedCodeReplyText` is declared by the
same two channels, the ones that start a sign-in by a code.
### The platform's verification handshake (GET)
Platforms of the Meta family confirm ownership of a webhook by sending a **GET** to the same path
with a challenge to echo back. Declare the query parameter that carries it, and Veriqa maps the GET
route beside the POST one:
```csharp theme={null}
adapters.AddChannel(
"acme-chat",
new ChannelRegistrationOptions
{
WebhookVerificationQueryKey = "hub.challenge",
ErrorMessageText = "Acme Chat is unavailable"
});
```
What makes the handshake genuine is **yours**: the GET is validated by the same
`ValidateWebhookAsync` as a POST, so the verify token, the spelling it arrives under and the mode
check are your adapter's business. Veriqa only knows which query parameter holds the value it has to
echo. The route is fail-closed, exactly like the POST one:
| Situation | Answer |
| - | - |
| Unknown tenant segment, or no adapter registered for the channel | `403` |
| `ValidateWebhookAsync` returns false / fails / throws | `403` |
| Validation passed, but the declared query parameter is missing or empty | `400` |
| Validation passed | `200` with the challenge as the body |
Declare nothing and the GET route is not mapped at all — the path answers `405`, which is the
behavior a channel whose platform has no handshake wants.
## 5. Show the channel in the sign-in window
A registered channel already appears as a button — by default labeled with its raw channel type and
without an icon. Implement the optional `IChannelDisplayMetadata` to give it a name and a glyph:
```csharp theme={null}
public sealed class AcmeChatChannelAdapter : IChannelAdapter, IChannelDisplayMetadata
{
public string DisplayName => "Acme Chat";
public string? IconSvgPath => """""";
}
```
`DisplayName` is a single non-localized string — brand names are not translated, exactly like
"Telegram"; a blank label — or one longer than 32 characters, which would break the window layout —
degrades to the raw channel type. `IconSvgPath` is the inner markup of a
`24×24` viewBox and is embedded into the page, so it must be static markup you own — never user input.
It is checked against an allowlist before rendering: plain shape elements only (`path`, `circle`,
`ellipse`, `rect`, `line`, `polyline`, `polygon`, `g`) with double-quoted attributes, no `style` and
no `on*` handlers, and at most 4096 characters. Anything else is dropped with a warning in the log
and the channel renders without an icon. A channel without a brand color renders with the
neutral primary color of the window.
If the user sees your channel's chat under a name of its own — a bot username, an account
handle — implement the optional `IChannelBotName` as well:
```csharp theme={null}
public sealed class AcmeChatChannelAdapter : IChannelAdapter, IChannelDisplayMetadata, IChannelBotName
{
public ValueTask GetBotNameAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult("@acme_bot");
}
```
The core puts the value, as it is, into the texts that tell the user which chat to look at: the
caption over the [code](#start-by-a-code-—-when-the-link-cannot-be-relied-on) and the line that
tells the user to confirm in the chat — the hop launch page shows it once it has taken the user into
the channel, and the sign-in window shows it once your channel has answered the start of a sign-in. The
method is called under the tenant of the transaction, as `GetDeepLinkAsync` is. `null`, a blank
value, a value longer than 32 characters or an exception produces the same texts, worded
without a name — the channel name is never put in its place.
**A configured name comes first.** The `BotName` key in the
[section of the channel](/docs/guides/channels#settings-every-chat-channel-shares) is read before your
adapter is asked, and a name stated there is shown instead of the one `GetBotNameAsync` returns —
the method is then not called at all. So a channel that does not implement `IChannelBotName` still
gets a name on the pages when the integrator states one.
Status messages your channel sends back to the user come from Veriqa's own localized defaults —
you do not have to supply them. For a sign-in these are "Sign-in confirmed" — or "Sign-in
confirmed. Tap Back to return to the sign-in page." when the sign-in was started on a phone or a
tablet — and "Sign-in declined"; for a confirmed action they are the neutral outcome receipts "Confirmed ✅",
"Declined ❌" and "Time expired ⌛". The error text for a failure while handling the answer —
"Something went wrong. Go back to the sign-in page and start again." — is the channel's own and does not depend on the transaction type.
### QR code size
The sign-in window draws every channel's QR in a box sized by the `--veriqa-qr-size` CSS token
(220px by default). You override it from your own stylesheet, whose path goes into
`Veriqa:AuthPageDesign:CustomCssPath`:
```css theme={null}
/* globally — for every channel */
:root { --veriqa-qr-size: 260px; }
/* for your channel only — the panel id is built from the channel type */
#veriqa-panel-acme-chat { --veriqa-qr-size: 260px; }
```
Override the token itself — it is the supported point of control over the size. A rule such as
`#veriqa-panel-acme-chat .veriqa-qr img { width: … }` gets the same result by bypassing the
token and is therefore forbidden: the next change to the window layout will drift it out of step
with the other sizes.
You may not shrink the QR below either of two thresholds:
* **200×200px** — the minimum physical target size for a camera;
* **2.86px per module** — the density: `box size ÷ the QR's total module count including the quiet
zone`.
There are two criteria and meeting one does not substitute for the other: a long deep link is
encoded by a higher QR version, so at the same box size the module shrinks past readability even
though the 200px rule is formally satisfied.
The box size is only half the picture; the other half is the resolution of the source PNG — its
pixels per module. There is a single rule, and you have to check it **right away, not only if you
enlarged the box**:
```
pixels per module ≥ box size ÷ your QR's module count (the quiet zone included), rounded up
```
Otherwise the image is stretched and its module grid blurs.
**Why this applies to the shipped 220px box too.** The `PixelsPerModule` default of 6 is sized for
the deep links the core ships: the most compact of those QRs is 45 modules, and `45 × 6 = 270px`
covers the box. In a 220px box the default suffices from `⌈220 ÷ 6⌉ = 37` modules up. A QR only gets
sparser than that from a **very short** link: `acme://c/8f3k9d` — 33 modules, `33 × 6 = 198px`,
already stretched in the shipped box. The link from §2, the one that carries the transaction id
(43 characters), does not fall into that case — it comes out at 45 modules and the default covers it.
So you need the entry below only if you keep a short code of your own with your own mapping to the
transaction. The host starts either way: the value is inside the allowed range, and your QR's module
count is unknown to the core — you are the only one who can check the rule.
If that is your case, the fix is an entry for your own channel:
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"QrCode": {
"PixelsPerModule": 6,
"PixelsPerModuleByChannel": { "acme-chat": 8 }
}
}
}
}
```
The 8 here is `⌈220 ÷ 29⌉`: 29 modules is the most compact QR that can exist at all (version 1 with
the quiet zone), so 8 covers the shipped box whatever the length of your deep link. If you enlarge
the box, recompute this value with the same formula.
The allowed range is **5…35**. The upper bound is that same formula applied to a 1000px box and the
same 29 modules, so the range covers any box that fits on a screen, for any channel. A value outside
it stops the host at startup — but landing inside the range confirms nothing in return: satisfying
the formula is still up to you.
## 6. Register it in the host
The host is the application that embeds Veriqa, so it references the server core:
```csharp Program.cs theme={null}
builder.Services.AddVeriqaAuthServer(builder.Configuration, builder.Environment, authServer =>
{
// OpenIddict store: the volatile in-memory one, chosen explicitly (there is no default —
// a host that says nothing here fails to start outside Development).
authServer.UseInMemoryOpenIddictStore();
authServer.ConfigureTransactionEngine(te => te.UseInMemoryStore());
authServer.AddChannelAdapters(adapters =>
{
adapters.AddTelegram();
adapters.AddChannel(
"acme-chat",
sp => new AcmeChatChannelAdapter(acmeChatOptions, sp.GetRequiredService>()));
});
});
```
Your channel's configuration section belongs to you: Veriqa neither knows nor binds it. Bind it in
the host and pass the result to your adapter — and keep the secrets in user-secrets or a secret
store, not in `appsettings.json`.
Use `AddChannel("acme-chat")` instead of the factory overload when the adapter's
dependencies can be resolved from the container.
**Declare the channel next to `AddChannel`.** `AddChannel` registers the adapter and its route, not
the channel's core-level declaration (`CoreChannelDeclaration`) — that is a separate step, the same
one the shipped channels take. The declaration is what puts your channel into `channels_enabled`,
the set of channels available at core level, and everything that reads that set skips a channel
missing from it: [hop mode](/docs/reference/configuration#veriqahopmode) never wraps its QR code, polling
never gets a tenant to run for, and the startup check of inbound verification stays off it. The host
still starts — with a warning in the log naming the channel that has an adapter but no declaration. How to declare
it is shown in [Working per tenant](#working-per-tenant).
## 7. Add the channel to the supported list
An adapter that ships as part of Veriqa gets registered in one more place: the
[Supported channels](/docs/guides/supported-channels) catalog and its version on the site
(`veriqa.app/channels`). That list is maintained by hand — a channel missing from it is not a
supported channel, however many other places mention it.
An entry states the same three things as every other one: which claims the channel adds **on top of
the mandatory ones**, whether confirmation happens inside the channel, and whether the user sends a
secret message — or the messenger hands the code to the bot by itself. A channel still in progress goes
under the in-development or planned groups, not the built-in one.
None of this applies to an adapter of your own that stays with you — the list describes what Veriqa
ships. Still, keep an entry like it in your own documentation: people ask the same questions
about any new channel.
## Working per tenant
Multi-tenancy is inside this SPI, not outside it. A channel of your own reads a tenant's
credentials, texts and limits the same way the shipped ones do — through the same public pieces, and
under the same one rule: **the tenant is stated by whoever legitimately knows it, and stated once.**
**The set of tenants comes from `IPollingTenantSource`.** Resolve it from the container and ask it
which tenants your channel is active for; a `null` element is the default implicit tenant of a
self-hosted installation. It has one precondition: the set is built from the core-level
**declarations** of the registered channels, and `AddChannel` does not register one — it registers
the adapter and its route. So declare your channel in the host, next to the `AddChannel` call:
```csharp Program.cs theme={null}
builder.Services.AddSingleton(sp => new CoreChannelDeclaration(
"acme-chat",
isEnabled: () => sp.GetRequiredService>().CurrentValue.Enabled,
usesPolling: () => sp.GetRequiredService>().Value.UsePolling));
```
Both facts are read through delegates, and the shipped channels close them over exactly these two
accessors. `isEnabled` is asked again every time the available set is built, so it has to read a
source that survives a re-read of the configuration: a monitor, not an options object bound once —
that one would freeze a deployment-side edit out of the answer. `usesPolling` is a startup
decision, and `IOptions` is what the shipped channels read it through. Both need the section in the
container (`builder.Services.Configure(builder.Configuration.GetSection("AcmeChat"))`),
which is one line more than the manual binding in §6 does for the adapter itself.
That `isEnabled` is also what puts your channel into `channels_enabled` at core level. Without the
declaration the tenant set comes back empty and no polling starts — and the startup check the note
in §2 describes stays off your channel; with it, an enabled channel without an `InboundVerification`
value stops the host exactly like a shipped one.
**State the tenant with a scope, around the processing, on your own background path:**
```csharp theme={null}
foreach (var tenantId in tenants)
{
// The tenant of THIS iteration: everything resolved below it — credentials, texts, limiter
// keys — reads that tenant's levels.
using var scope = ChannelTenantContext.BeginScope(tenantId);
await ProcessUpdatesAsync(tenantId, cancellationToken);
}
```
A health probe of your own runs outside any request and reports on the installation itself, so it
states the default tenant **explicitly** — `ChannelTenantContext.BeginScope(null)` — rather than by
omission: a credential read with no scope open at all is indistinguishable from a path that forgot
to state its tenant, and nothing downstream can tell the two apart for you.
**On the webhook you do not open a scope.** The tenant of an incoming request is already stated —
by the route (`/api/channels/{channelType}/webhook/{tenant}`), before your adapter is called. One of
your own opened there would repoint every credential read below it, the core's included, at a tenant
of your choosing instead of the one the host named.
**You resolve your own credentials; you do not receive them.** Declare your key and resolve it
through the public `IConfigurationResolver` with `ResolutionContext.ForTenant(tenantId)` — the same
canonical resolver and the same level order the shipped channels go through. The public client factory
`IChannelClientFactory` is not needed for that: it builds and caches a *client* per
`(channel, tenant)`, which is a different job. How a key of your own is declared — the catalog, the
address on each level, the registration — is in
[Declare your own configuration keys](/docs/guides/config-keys).
## An HTTP channel adapter in any language
Everything above is the .NET path: your adapter runs inside the host. The second path needs no .NET
and no host code at all. The **HTTP channel** is an adapter shipped with Veriqa that stands in for
yours inside the host and forwards the work over HTTP to an **external adapter** — a service you write
in any language and run next to Veriqa or inside your own perimeter. The channel is added by
configuration.
The wire contract between Veriqa and an external adapter is version `1.0` and ships with the status
**`preview`**. Declaring it stable is a separate decision; until then a revision of the contract may
still change it.
### How the traffic flows
* **The platform talks to your adapter, not to Veriqa.** Your adapter receives the platform's webhooks
or polls it, verifies them and answers the platform's challenge handshake itself. Veriqa maps no GET
route for an HTTP channel.
* **Your adapter posts events to Veriqa** on the routes every channel has,
`/api/channels/{channelType}/webhook` or `/api/channels/{channelType}/webhook/{tenant}`. An event of
a transaction goes to the route of **that transaction's tenant** — the one the `tenant` field of the
`deep-link` and `send-prompt` calls of that transaction carried; `null` means the route without a
tenant segment. Veriqa does not tell you the tenant again: keep the mapping "transaction → tenant"
yourself.
* **Your public entry is yours to protect.** Veriqa's rate limit guards its own routes; the
entry your adapter opens to the platform needs protection against junk traffic of its own.
* **Veriqa calls your adapter at one address**, `Http:BaseUrl`, for everything it needs from the
channel.
* **The platform credentials stay with your adapter.** Veriqa never asks for them and never stores
them.
* **One registration is one external adapter**: one address, one transport secret, one set of channel
facts. Without `Http:Tenant` it serves every tenant of the installation. A tenant that brings an
implementation of its own gets a registration of its own, under its own name (for example
`shop-a-zalo`), with `Http:Tenant` naming that tenant.
* **The standard Veriqa image always registers HTTP channels**: a channel is added with environment
variables and a restart of the container, without rebuilding the image. An embedded host references
the `Veriqa.Core.ChannelAdapter.Http` package and adds one call:
```csharp Program.cs theme={null}
authServer.AddChannelAdapters(adapters => adapters.AddHttpChannels());
```
### Configuration
Every section `Veriqa:Channels:{channelType}` with an `Http` subsection becomes one HTTP channel under
the name of the section:
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"acme-chat": {
"InboundVerification": "signature",
"Http": {
"BaseUrl": "https://acme-adapter.internal/",
"DisplayName": "Acme Chat",
"Capabilities": {
"SupportsInChannelConfirmation": true,
"DeliversOutcomeNotice": true
}
}
}
}
}
}
```
The same channel in environment variables, with the transport secret — keep it there or in a secret
store, not in `appsettings.json`. A channel type with a dash cannot be exported from a shell, but the
`environment:` of a compose file and `docker run -e` take it:
```bash theme={null}
Veriqa__Channels__acme-chat__InboundVerification=signature
Veriqa__Channels__acme-chat__Http__BaseUrl=https://acme-adapter.internal/
Veriqa__Channels__acme-chat__Http__TransportSecret=whsec_
Veriqa__Channels__acme-chat__Http__Capabilities__SupportsInChannelConfirmation=true
```
What to know about the keys (the full list is in the
[configuration reference](/docs/reference/configuration#keys-in-the-declaration-catalog)):
* **The channel facts and the button are stated by the operator**, with `Http:Capabilities:*` (one key
per property of `ChannelCapabilities`), `Http:DisplayName` and `Http:IconSvgPath`. There is no
network manifest: Veriqa does not ask your adapter what it can do. A fact left unstated takes the
safe default of `ChannelCapabilities` — every boolean `false`, `plain_text` as the only content kind,
no emoji rule of the channel's own (`RendersEmoji` unset), no phone number
([The phone number](#the-phone-number)).
* **`Http:Tenant`** names the tenant that owns the registration. It serves that tenant alone; for every
other tenant the channel does not exist — not on the inbound route, not for events, not in outbound
calls. Unset, the registration serves every tenant of the installation.
* **`Http:AdapterSettings`** is an opaque string, up to 8192 characters, that Veriqa forwards verbatim
with every call and never reads. It has a core level and a tenant level; the value of a tenant
replaces the core value entirely.
* **`Http:AllowInsecureHttp`** admits an `http` base URL. Use it only for an isolated internal network
(a neighboring container); Veriqa writes a warning at startup.
* **Every `Http:*` value is fixed at startup**, except `Http:AdapterSettings`, which is resolved per
call. Changing the others takes a restart.
* **The name of the channel** follows the format of [Name your channel](#3-name-your-channel), and the
names of the channels shipped with Veriqa are reserved even when the shipped channel is not wired
into the host: `telegram`, `whatsapp`, `max`, `email`. The reserved list grows as new built-in channels ship.
* **A configuration error found at startup stops the host**, with a message naming the key or the
channel section — for example, a missing or relative base URL, an `http` base URL without
`Http:AllowInsecureHttp`, a missing `Http:TransportSecret`, a secret not in the `whsec_` form, a
secret shared by two registrations, a forbidden deep-link scheme, a timeout not in the `hh:mm:ss`
form, `Http:AdapterSettings` longer than 8192 characters, a reserved name or one another channel of
the host already has. An empty `Http:Tenant` is an error too: to serve every tenant, remove the key.
Unlike an `AddChannel` channel (the note in §2), `InboundVerification` **is** checked at startup
for an HTTP channel: the registration declares the channel to the core, so an enabled HTTP channel
without a value stops the host.
#### The phone number
A channel that can give the user's phone number says so in `Http:Capabilities:PhoneNumber` — the same
fact a built-in channel declares in code (see [Phone number on request](#phone-number-on-request) and
[Phone number](/docs/guides/phone-number)). Left unstated, the channel gives no number and is not offered to
a sign-in that requires one.
| Key (under `Http:Capabilities:PhoneNumber`) | Values | Unstated |
| - | - | - |
| `Acquisition:{n}` | a list: `Automatic` — the number comes with the identity of your events, nothing is asked; `OnRequest` — Veriqa asks for it with a `send-message` of the kind `phone_number_request`. Both may be listed | none — the channel gives no number |
| `Proof` | `Proven` — a number from this channel is proven to belong to the user; `NotProven` | `NotProven` |
```json appsettings.json theme={null}
"Capabilities": {
"SupportedMessageKinds": [ "plain_text", "phone_number_request" ],
"PhoneNumber": {
"Acquisition": [ "OnRequest" ],
"Proof": "Proven"
}
}
```
```bash theme={null}
Veriqa__Channels__acme-chat__Http__Capabilities__SupportedMessageKinds__0=plain_text
Veriqa__Channels__acme-chat__Http__Capabilities__SupportedMessageKinds__1=phone_number_request
Veriqa__Channels__acme-chat__Http__Capabilities__PhoneNumber__Acquisition__0=OnRequest
Veriqa__Channels__acme-chat__Http__Capabilities__PhoneNumber__Proof=Proven
```
The names are matched without regard to case. Five mistakes stop the host from starting: `PhoneNumber`
written as a single value instead of a section, a value outside the names above, `Acquisition`
written as a single value instead of a list, `Proof: Proven` with no `Acquisition`, and `OnRequest`
without `phone_number_request` in `SupportedMessageKinds` — the request would never reach your
adapter.
`Proof` is the **ceiling** of what your events may state. `identity.phone_number_verified: true` of an
event is taken only from a registration that declares `Proven`; from any other, Veriqa lowers it to
`false` and writes a warning. `false` and an absent field are never raised: declaring `Proven` does
not make a number proven that your adapter said nothing about.
#### The start by a code
A channel whose link cannot carry the transaction, or whose platform does not always deliver it,
declares the [start by a code](#start-by-a-code-—-when-the-link-cannot-be-relied-on) in
`Http:Capabilities:CodeStart` — the same two members a built-in channel sets in code. The core shows
the code on its pages, the user sends it to your bot, and your adapter posts it with the event
`auth_start_by_code` (see [The wire contract](#the-wire-contract)).
| Key (under `Http:Capabilities:CodeStart`) | Values | Unstated |
| - | - | - |
| `LinkCarriesTransaction` | `false` — your `deep-link` returns a link that only opens the chat, and the code is the only way to start; `true` — the link carries the transaction, and the code is a fallback beside it. **Stating this key declares the start by a code** | no start by a code |
| `MessageTemplate` | the message the user sends, `{code}` standing for the code exactly once | the user sends the code alone; an empty value counts as unstated |
```json appsettings.json theme={null}
"Capabilities": {
"CodeStart": {
"LinkCarriesTransaction": false,
"MessageTemplate": "/veriqa {code}"
}
}
```
```bash theme={null}
Veriqa__Channels__acme-chat__Http__Capabilities__CodeStart__LinkCarriesTransaction=false
Veriqa__Channels__acme-chat__Http__Capabilities__CodeStart__MessageTemplate="/veriqa {code}"
Veriqa__Channels__acme-chat__Http__UnmatchedCodeReplyText=
```
`Http:UnmatchedCodeReplyText` is the reply to a code that names no live sign-in; unset,
`Http:StaleLinkReplyText` answers instead. The template is judged where the channel is shown, as for
a built-in channel, not at start. Three slips stop the start: `CodeStart` written as a single value
instead of a section, `LinkCarriesTransaction` that is not `true` or `false`, and `MessageTemplate`
without `LinkCarriesTransaction`. With `LinkCarriesTransaction: true` the spare code block and its
setting `SpareStartCodeEnabled` work for the HTTP channel as for any channel. An `auth_start_by_code`
from a registration that does not declare the start is not processed.
For now the start by a code is open only to a registration without an owner. A registration that
declares it and sets `Http:Tenant` stops the host at start, with a message naming both keys.
### The wire contract
Every call from Veriqa is a signed `POST` of a JSON envelope to `Http:BaseUrl`: `version`, `operation`,
`channel_type` (one entry may serve several registrations), `tenant` (or `null`), `adapter_settings`
(or `null`) and `payload`. The answer is `200` with an envelope of `version` and exactly one of
`result` or `error` (`code` and `message`).
| Operation | What your adapter does |
| - | - |
| `send-prompt` | Sends the confirmation question with two buttons. Veriqa passes the text it rendered, the button captions and the structured context, so you may render on your own |
| `send-message` | Sends a message to the user. Its `kind` is one the registration declares. The kind `phone_number_request` comes with the member `phone_number_request` — the captions of the share button and of the second button, the secondary action (`skip` or `cancel_sign_in`) and the transaction; how you show the two is your choice. For every other kind the member is `null` |
| `report-outcome` | Shows the user where a transaction stands: `outcome` is its final state or `awaiting_claims` — the sign-in is confirmed and waits for the details the application still asks for. A final state may carry `phone_request_message`, the phone number request to take down. `expired` is reported for every transaction started from the channel — with `prompt_message` set to `null` when the question was not asked in the chat or `send-prompt` returned no reference. `declined` also comes with nothing pressed — when the user cancels the sign-in on the sign-in window while its question waits in the chat — with `prompt_message` set to `null` when `send-prompt` returned no reference. Called only when the channel declares `DeliversOutcomeNotice`; for a press on a sign-in that is already waiting, `awaiting_claims` is sent only when the channel also declares `SupportsMessageUpdate` |
| `deep-link` | Returns the `url` that takes the user into your channel for a transaction |
| `health` | Reports whether the adapter is healthy, its version and the highest wire version it speaks |
| Event | When your adapter posts it |
| - | - |
| `auth_start` | The user reached your channel through the deep link of a transaction |
| `auth_start_by_code` | The user sent your bot the code of the sign-in page — only for a registration that declares [the start by a code](#the-start-by-a-code). `code` is what the user sent, with the wrapping of your `MessageTemplate` stripped and nothing else done to it: Veriqa drops spaces and dashes and checks the length. The event names no transaction |
| `auth_confirm` | The user pressed the confirm button |
| `auth_decline` | The user pressed the decline button |
| `phone_shared` | The user shared their phone number. `phone_number` is in E.164 with the leading plus — `+` and 1 to 15 digits, the first not zero, no separators or spaces. Veriqa does not repair a number in another spelling: the event is not processed, the answer is still `200`, and a warning goes to the log |
| `phone_step_action` | The user chose the second action of a phone number request — through a control that carries a payload. `action` is `skip` or `cancel_sign_in`, as the request named it; `transaction_id` is the one of the request |
| `unaddressed` | A direct message from a known sender that names no transaction. The optional `text` carries the text of the message: a button that carries no payload arrives as the text of its caption, and that is how such a platform answers the second action of a phone number request |
| `unrelated` | Anything Veriqa has no business answering |
The fields of every payload, result and event are in the machine-readable
[JSON Schema of the wire](#conformance-suite) — this guide does not repeat them. Rules the schema does
not express:
* **Error codes.** The codes the wire names are in the schema, and any other code is accepted too. The
code of an `error` is written to the log and decides nothing else, with one exception:
`channel_cannot_continue` in the answer to `send-prompt` ends the transaction in failure right away.
Any other `error` acts by its operation, and the call is not repeated after it:
* `send-prompt` — the transaction stays open and waits for a new `auth_start` from the user or the
end of its lifetime;
* `deep-link` — the user gets no way into your channel: the sign-in page is rendered without it;
* `send-message`, `report-outcome` — the refusal is logged and counted in the metrics, nothing more.
* **`deep-link` is idempotent**: a repeated call for the same transaction returns the same `url`
and creates no new state.
* **Answers are bounded.** An answer is read up to 1 MB and refused past it, redirects are not
followed, and a deep link is accepted only as an absolute URI with the `https` scheme or a scheme
listed in `Http:DeepLinkSchemes`, no longer than 2048 characters.
* **Timeouts and retries.** One attempt waits `Http:OperationTimeout` (10 s by default); `deep-link`
waits `Http:DeepLinkTimeout` (2 s) because it stands on the path of the sign-in page. `send-prompt`,
`send-message` and `report-outcome` are retried on a transport failure — no connection, a timeout,
a `5xx`, a `429` — up to three times, all attempts within `Http:RetryBudget` (20 s). Nothing else is
retried: a `200` with `error`, a `3xx`, any other `4xx`, an oversized or malformed answer.
`deep-link` and `health` are attempted once.
* **The second action of a phone number request comes back in either of two ways**, whichever your
platform allows: the `phone_step_action` event, or an `unaddressed` event whose `text` equals the
caption of the second button. An `action` Veriqa does not know makes the event `unrelated`. The share
button itself is answered with `phone_shared`.
* **The phone number of an identity.** `identity.phone_number` of any event is taken as the user's
number — the automatic way — and `identity.phone_number_verified: true` only up to the `Proof` the
registration declares (see [The phone number](#the-phone-number)).
* **`auth_start_by_code` goes on the route your bot's other events use.** It names no transaction, so
the rule of the transaction's tenant does not apply to it: Veriqa looks the code up across the whole
installation, whichever route the event came on, and takes the tenant from the transaction it finds.
* **Refuse an event envelope on your call entry.** A body with `event` instead of `operation` that
arrives at `Http:BaseUrl` is an event reflected back at you; answer it with anything but a `200`
with `result`.
* **Serve calls while your own request is open.** On `auth_start` Veriqa sends `send-prompt` to your
adapter **before** it answers your `POST` of the event. An adapter that cannot take calls
concurrently with its own requests to Veriqa times the prompt out, and the transaction waits for its
lifetime.
### The signature
Both directions are signed with the transport secret of the registration by the
[Standard Webhooks](https://www.standardwebhooks.com/) scheme `v1` (HMAC-SHA256) — every language has
a library for it. The headers are `webhook-id`, `webhook-timestamp` and `webhook-signature`, and a
timestamp is accepted within 5 minutes either way of the receiver's clock.
* **Rotation without downtime.** Put the new secret in `Http:TransportSecret` and the old one in
`Http:PreviousTransportSecret`: the sender then signs with both, and the receiver accepts either.
Veriqa writes a warning at startup until the previous secret is removed.
* **Answers are not signed**: their authenticity rests on the connection Veriqa opened to the
configured address — which is why `https` is the default.
* **A wrong signature is `403`**, without an envelope and without any effect, in both directions: Veriqa
answers your unsigned or mis-signed event that way, and your adapter must answer such a call the
same way.
* **Every registration has a secret of its own.** Two registrations sharing one would let one pass as
the other, so the host refuses to start.
### Idempotency
* **The `webhook-id` of `send-prompt`, `send-message` and `report-outcome` is the idempotency key.**
A repeat with a key you have already served successfully sends nothing new and returns the result of
the first call — the same message reference. `deep-link` and `health` carry a unique `webhook-id`
that is not a key.
* **The `webhook-id` of your event is yours to choose**: unique per event and unchanged when you
deliver the same event again. Veriqa derives the keys of the `send-prompt` and `report-outcome` an
event causes from it, so a redelivered event does not ask the question twice.
* **A failed attempt does not bind the key**: the next attempt with it is served anew.
* **Retention is the operator's choice**, within two floors: no shorter than the retry budget of
Veriqa (`Http:RetryBudget`), and, for a key derived from an event, no shorter than the window in which
your adapter itself may deliver that event again.
### Wire version and the `preview` status
Every envelope, in both directions, carries `version` — the wire version of its sender, `major.minor`;
the first is `1.0`, with the status `preview`. Within one major version changes are additive only — a
new optional field, operation, enumeration value or event type — and each comes with the behavior of
a receiver that does not know it: an unknown field is ignored, an unknown operation is answered with
`unsupported_operation`, an unknown question variant is shown as a question without details, an
unknown event is taken by Veriqa as `unrelated`. Removing or renaming a field or changing its meaning
takes a new major version. Answer a call of another major version with `unsupported_wire_version`;
Veriqa, in turn, does not process an event of another major version and takes such an answer as
outside the contract. Every event also carries `adapter_version` — the semver version of your adapter.
### Conformance suite
Veriqa ships an executable conformance suite that checks your adapter against the wire, whatever it
is written in. It checks three things:
* **calls through the real wrapper** — every operation, a repeat under the same key, a wrong secret
and a timestamp outside the window. These calls are made by the very HTTP channel code Veriqa runs,
so the suite cannot drift from Veriqa;
* **malformed calls** Veriqa never makes — unsigned, with an unknown field, variant or operation, of a
foreign major version, an event envelope reflected at your call entry — sent by a separate sender;
* **the receiver of events** — it plays Veriqa for your events: checks the signature and the schema
of each, calls `send-prompt` before it answers `auth_start`, and checks that every event came on the
route of its transaction's tenant.
Two scenarios check the phone number, and each joins a run only when the registration declares its
way: `send-message.phone-number-request` for `OnRequest`, `identity.phone-automatic` for `Automatic`. A
scenario missing from the report means its way is not declared. An event that states
`phone_number_verified: true` without a declared `Proof: Proven` fails `events.all-valid`.
A registration that declares [the start by a code](#the-start-by-a-code) gets the scenario
`flow.code-start`: the suite makes up a code, has it sent to the bot of a deep link — by the platform
stub with `send-text`, or by you on an `ACTION` line — and expects `auth_start_by_code` with those
digits from the user of the run. With `LinkCarriesTransaction: false` the scenarios that start a
transaction by its link — `flow.confirm`, `flow.decline`, `identity.phone-automatic` — are left out of
the run: a link that only opens the chat starts nothing. The suite then does not play the confirmation
after a start by a code — its made-up code has no transaction behind it — so check that path with a
live run.
The suite is a container image, built from the root of the repository:
```bash theme={null}
docker build -f src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Dockerfile -t veriqa-http-channel-conformance .
```
Run it next to your adapter with the configuration of your channel in the same
`Veriqa__Channels____*` keys Veriqa reads, plus keys of its own under `Conformance__*`:
`ChannelType` and `ChannelUserId` are required; `Mode`, `Tenants` and `ReportPath` are optional.
| `Conformance__Mode` | What runs | For |
| - | - | - |
| `calls` (default) | the calls alone | the CI gate of your adapter |
| `interactive` | the calls and the receiver; you open the deep link on the platform and press the buttons yourself | a manual check of the event half |
| `automatic` | the calls, the receiver and a stub of the platform that plays the user; `Conformance__PlatformStubUrl` is required | adapters that come with such a stub; its contract is in the suite's README |
In `interactive` and `automatic` the suite stands in for Veriqa: it listens on port `8080` of the image
on the same paths, `/api/channels/{type}/webhook` and `/api/channels/{type}/webhook/{tenant}`, so your
adapter changes only the base address of Veriqa it posts to. How long it waits for an event is
`Conformance__EventTimeoutSeconds`.
The suite prints one line per scenario — `PASS `, `FAIL : ` or `N/A : ` —
writes a JSON report to `Conformance__ReportPath`, and exits with `0` (no scenario failed), `1` (at
least one failed) or `2` (the run could not be carried out; the message names what to fix). The full
list of scenarios is in the README of `src/core/Veriqa.Core.ChannelAdapter.Http.Conformance`.
The machine-readable schema of the wire (JSON Schema) ships with the suite: at
`/schema/http-channel-wire-v1.schema.json` in the image and in
`src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Schema/` in the repository.
### Reference adapters
Three external adapters in the repository show the whole contract on a real platform:
* `samples/python/custom-channel-telegram/` — Telegram, in Python;
* `samples/node/custom-channel-whatsapp-twilio/` — WhatsApp through Twilio, in Node;
* `samples/node/custom-channel-whatsapp-baileys/` — WhatsApp through Baileys, in Node — a working adapter for debugging, small teams and your own use; WhatsApp may ban the number.
Each runs next to Veriqa from its own folder:
```bash theme={null}
cp .env.example .env # fill in the transport secret and the platform credentials
docker compose up
```
Between them they show every operation and every event of their channel, the signature in both
directions, idempotency (a repeat sends no second message), credentials per tenant,
`adapter_settings` read as a setting of the adapter's own, and the protection of the adapter's own
public entry. The Twilio adapter also answers `channel_cannot_continue` when the platform cannot
deliver the question; the Telegram adapter splits the updates between Veriqa and a bot of your own.
Two of them show the two ways of obtaining the phone number: the Telegram adapter **on request** — it
shows the request as a keyboard with a contact button and returns the second button as the text of an
`unaddressed` event; the Twilio adapter **automatically** — the number of the sender travels in the
identity of every event.
The Telegram adapter also shows [the start by a code](#the-start-by-a-code) behind a switch,
`CODE_START`: `off` by default, `fallback` for a registration with `LinkCarriesTransaction: true`,
`only` for one with `false`; its CI runs the suite a second time in the `only` mode.
The Baileys adapter is a working one, meant for debugging WhatsApp scenarios, for small teams and for your
own use: Baileys is an unofficial WhatsApp Web client on a number of your own, and WhatsApp may ban that
number. It asks the question as a
poll and fetches the messages over its own outbound session.
Each README documents its configuration, its `InboundVerification` value and a manual live run.
They are samples to learn from and copy, not a catalog of community adapters.
### `InboundVerification` of an external adapter
The rule of [Declare the inbound verification level of your channel — and document it](#declare-the-inbound-verification-level-of-your-channel-—-and-document-it)
holds for an external adapter unchanged: the platform's traffic is verified by your adapter, so
**you name the value for every inbound mode of your channel** in your documentation, and the operator
declares it in the section of the channel. There is no default. The signature of the transport between
your adapter and Veriqa is always checked and is not what this value describes.
The reference adapters name theirs:
| Adapter | Inbound mode | `InboundVerification` |
| - | - | - |
| Telegram | `webhook` — a secret token in a header of every update | `shared_secret` |
| Telegram | `polling` — the adapter fetches the updates | `outbound_fetch` |
| WhatsApp through Twilio | the webhook signed by Twilio | `signature` |
| WhatsApp through Baileys | the adapter's own outbound WhatsApp Web session | `outbound_fetch` |
In the same documentation list the top-level keys of your `raw_metadata` that carry personal data,
the native user id of the platform included: the integrator decides from that list what they may keep.
# Configuration & customization
Source: https://veriqa.app/docs/guides/customization
Channels, sign-in page design, localization and Veriqa extension points.
The sign-in page and Veriqa's behavior are configured through settings, and key services can be
replaced with your own implementation. Below is what integrators touch most often.
## Channels
Each channel is wired in `AddChannelAdapters` and activated by the `Veriqa:Channels:{Channel}` section
with `"Enabled": true`. Eight channels come out of the box, each as its own package: the base set
as the `Veriqa.Core.BaseChannels` metapackage (Telegram, WhatsApp, Email), MAX, Instagram, Messenger, WeChat and Viber separately
(`Veriqa.Core.ChannelAdapter.Max`, `Veriqa.Core.ChannelAdapter.Instagram`,
`Veriqa.Core.ChannelAdapter.Messenger`, `Veriqa.Core.ChannelAdapter.WeChat`,
`Veriqa.Core.ChannelAdapter.Viber`), and a single channel as just that channel's package. See
[channel setup](/docs/guides/channels) and [supported channels](/docs/guides/supported-channels).
```csharp Program.cs theme={null}
authServer.AddChannelAdapters(adapters =>
{
adapters.AddTelegram();
adapters.AddWhatsApp();
adapters.AddMax();
adapters.AddEmail();
});
```
Email works in two modes: **Pull** (magic link — the user follows a link from the email) and
**Push** (the one-tap email — the user sends a pre-filled message). Push is off by default and is enabled
with `Veriqa:Channels:Email:PushEnabled`. The Email channel endpoints are mapped by
`MapVeriqaAuthServer` automatically when the channel is enabled — see the
[quickstart, step 3](/docs/quickstart/dotnet#3-build-the-middleware-pipeline).
Telegram receives updates in `Webhook` mode (default) or `Polling` — set by
`Veriqa:Channels:Telegram:UpdateMode`. For `Webhook`, provide `WebhookBaseUrl` and `WebhookSecretToken`.
A client can restrict sign-in to a specific channel via `acr_values=channel:{type}` — see the
[quickstart](/docs/quickstart/dotnet#9-channel-selection-via-acr-values).
## Email: sending and receiving
Email is enabled through two independent modes under the `Veriqa:Channels:Email` section:
* **Pull** (magic link) — Veriqa sends an email with a link, the user follows it. On by default
(`PullEnabled`).
* **Push** (the one-tap email) — the user sends a pre-filled message to the inbound address, Veriqa
processes the incoming message. Off by default (`PushEnabled`) — enable it together with the inbound setup and
a sender verification policy. The flag is read at startup: restart the service after changing it.
```json appsettings.json theme={null}
{
"Veriqa": {
"Channels": {
"Email": {
"Enabled": true,
"InboundVerification": "shared_secret",
"PublicBaseUrl": "https://your-domain.com",
"PreferredMode": "Push",
"PullEnabled": true,
"PushEnabled": false,
"Outbound": {
"Smtp": {
"Host": "smtp.your-provider.com",
"Port": 587,
"UseSsl": false,
"Username": "…",
"Password": "…"
}
},
"Inbound": {
"Provider": "Webhook",
"InboundAddress": "login@your-domain.com",
"UsePlusAddressing": true,
"VerificationPolicy": "DmarcAlignedPass",
"WebhookSecretToken": "…"
}
}
}
}
}
```
* `PublicBaseUrl` is required when the channel is enabled — magic links and QR codes are built from it.
* `Outbound:Smtp` — SMTP for Pull mode (`Port` 587 = STARTTLS; `UseSsl: true` = implicit TLS,
port 465). With credentials set, STARTTLS is mandatory unless `RequireStartTls: false` (default
`true`). Keep the password in a secret store, not in `appsettings.json`.
* `Inbound` — inbound email for Push mode: `InboundAddress`, `UsePlusAddressing` (embeds the
correlation token into an address like `login+{token}@…`), `WebhookSecretToken` to validate the
provider webhook.
* `VerificationPolicy` — sender verification: `DmarcAlignedPass` (default), `AllowListOnly` (only the
domains in `AllowedDomains`). The `None` value is forbidden in production.
## Design presets and theme
The page's look is set by the preset in `Veriqa:AuthPageDesign:Preset`:
| Preset | Description |
| - | - |
| `Default` | Standard light theme (default). |
| `Dark` | Dark theme. |
| `Minimal` | No decorative elements: the brand row is not rendered and the container has no border. The preset does not change what the page contains — QR visibility is set by [`ShowQrCode`](#qr-code-visibility). |
| `Branded` | The client's primary color (see below). |
The color scheme is controlled separately — `Veriqa:AuthPageDesign:Theme`:
| Theme | Description |
| - | - |
| `Auto` | Follows the OS setting (`prefers-color-scheme`). Default. |
| `Light` | Light theme. |
| `Dark` | Dark theme. |
An explicit `Theme` (`Light` / `Dark`) takes precedence over the preset's color scheme; with
`Theme: Auto` the `Dark` preset also yields a dark page. The theme sets `data-theme` on ``,
the preset sets `data-preset`. Animations respect `prefers-reduced-motion`.
The theme applies to **every** page Veriqa generates, not to the sign-in window alone: the sign-in
confirmation page, the page that collects missing details, the expired-link page and the email
sign-in pages all carry the same `data-theme`. With `Theme: Auto` they follow the visitor's system
setting, so a stylesheet of your own that assumes light colors will get dark ones on a device set
to a dark theme — pin the scheme with `Theme: Light` if it must not.
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"Preset": "Default",
"Theme": "Auto"
}
}
}
```
## QR code visibility
Whether the sign-in page shows the QR code is set by the `ShowQrCode` key of the
`Veriqa:AuthPageDesign:QrCode` section — the same section that holds the code's resolution
([configuration reference](/docs/reference/configuration#veriqaauthpagedesignqrcode)):
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"QrCode": {
"ShowQrCode": "DesktopOnly"
}
}
}
}
```
| Value | Behavior |
| - | - |
| `Always` | The QR code is shown on every device, and several channels are laid out as tabs. |
| `DesktopOnly` | The QR code is hidden on a phone, which cannot scan its own screen; there the channels are laid out as buttons. A tablet and a desktop get the tabs and the QR code. Default. |
| `Never` | The QR code is never shown — only the channel buttons, on any device. |
Where this section says tabs, [`ChannelLayout: Grid`](#channel-layout) puts the channels side by
side instead.
Two properties of `DesktopOnly` its name does not reveal:
* **A tablet counts as a desktop.** The QR code is scanned by *another* device's camera, so a device of
uncertain type keeps the code rather than losing it.
* **When device detection fails, the QR code is shown.** The device is determined by the page itself,
and any failure of that check — a blocked script, an unrecognized User-Agent — leaves the QR code and
the tabs in place. A page whose only way in is a button the user cannot reach is worse than a QR code
shown where it is useless.
The preset does not affect QR visibility: `Preset` and `ShowQrCode` are independent settings.
**After an update.** The default used to be `Always`. A deployment that does not set `ShowQrCode` now
shows a phone the channel buttons instead of the QR code and the tabs; a tablet and a desktop are
unchanged. To keep the previous page, set `"ShowQrCode": "Always"`.
### Channel buttons
On a phone under `DesktopOnly`, and on any device under `Never`, the page has no tab strip: the
channels are listed as buttons, one per channel, in the order of the channel set.
* The "Open in …" button of a messenger leads straight into it.
* Email with a mail-client button keeps that button, and the "Sign in via email link" link under it.
* Email with an address form gets a button of its own that reveals the form in place. A page whose
only channel is this one shows the form straight away — there is nothing to choose.
* A [channel hint](/docs/reference/configuration#veriqalocalization-and-veriqachanneldisplay) appears under
the button of its own channel.
Under `Never` the server renders this layout itself, so it needs no script. Under `DesktopOnly` the
page is rendered with tabs and switches to buttons once it has recognized a phone.
### Channel button visibility
The QR code is the way into a channel from *another* device; the button that leads into it — "Open in …",
and Email's "Send confirmation email" — is the way in on *this* one. Whether that button is shown
is set by the `ShowChannelButton` key of the `Veriqa:AuthPageDesign:ChannelButton` section, the
counterpart of `ShowQrCode`:
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"QrCode": {
"ShowQrCode": "DesktopOnly"
},
"ChannelButton": {
"ShowChannelButton": "MobileOnly"
}
}
}
}
```
| Value | Behavior |
| - | - |
| `Always` | The button is shown on every device. Default: a deployment that sets nothing keeps the page it had. |
| `MobileOnly` | The button is shown on a phone only. Every other device — a desktop, a tablet, a television, a kiosk — is left with the QR code, which is scanned with a phone. |
| `Never` | The button is never shown: the QR code is the only way into a channel, on any device. |
The pair in the example is what a screen read from across a room needs — a television, a kiosk, a
projector: the QR code on the big screen, the buttons on a phone, one way in on each.
What the setting covers:
* **The buttons that lead into a channel:** "Open in …" of every channel, a third-party one
included, and Email's "Send confirmation email". With that one hidden, the Email panel keeps its QR code
and the "Sign in via email link" link.
* **Not the controls that stay on the page:** the address form of Email, the button that reveals
that form among the [channel buttons](#channel-buttons) and the links switching the two Email
sections are shown whatever the value.
* **The sign-in window and the page that opens a confirmation.** The pages a phone gets after
scanning a hop QR code keep their buttons whatever the value: there the button is the only way
forward.
Two properties of `MobileOnly`:
* **A tablet does not count as a phone**, exactly as under `DesktopOnly`: it gets the QR code and no button.
* **When device detection fails, the button stays hidden** — the opposite of the QR code, which stays
shown. A blocked script or an unrecognized User-Agent leaves a phone without the buttons; the QR code,
which every allowed pair keeps on such a page, is then the way in.
Like `ShowQrCode`, the key can be set at each ownership level, and the nearest level that sets it
wins: the `ui_config` record (`ChannelButton:ShowChannelButton` under
`Veriqa:UiConfigurations:Records:`) → the client entry (the same member in its entry of
`Veriqa:OpenIddict:Clients`) → the global section. The tenant level sits between the last two; as
with the [footer block](#footer-block-of-the-sign-in-page), no reader for it is registered out of
the box.
### Allowed combinations of the two visibilities
The two keys are resolved independently, and each may come from a level of its own — a client entry
that hides the buttons over a global section that hides the QR code. Every class of devices must be left
with a way in, the QR code or the button:
| `ShowQrCode` | `ShowChannelButton: Always` | `MobileOnly` | `Never` |
| - | - | - | - |
| `Always` | allowed | allowed | allowed |
| `DesktopOnly` | allowed | allowed | **not allowed** — a phone has neither |
| `Never` | allowed | **not allowed** — every device but a phone has neither | **not allowed** — no device has either |
A pair that is not allowed neither stops the host nor breaks the page: the page is rendered as if
`ShowChannelButton` were `Always`, and a warning naming both configured values goes into the log —
whenever such a page is requested, not once at startup. The check looks at the two settings alone,
not at the channels the page carries.
### The instruction follows both settings
The built-in instruction under the title names only what the page shows. It goes into the markup in
**three wordings** — for a page with the QR code and the button, with the QR code alone and with the button
alone — and the stylesheet of the page shows exactly one of them, by the same attributes of the root
element that show and hide the QR code and the buttons (`data-qr-visibility`,
`data-channel-button-visibility`, `data-device`).
| The page shows | One channel | Several channels, a QR code for each | Several channels behind one QR code |
| - | - | - | - |
| the QR code and the button | `Scan the QR code or tap the button to sign in via {0}` | `Choose a channel and scan the QR code or tap the button` | `Scan the QR code or tap the channel button` |
| the QR code alone | `Scan the QR code to sign in via {0}` | `Choose a channel and scan the QR code` | `Scan the QR code to sign in` |
| the button alone | `Tap the button to sign in via {0}` | `Choose a channel and tap the button` | `Tap the channel button to sign in` |
So a television under `ShowChannelButton: MobileOnly` reads "Scan the QR code…" with no button
promised, and a phone under `ShowQrCode: DesktopOnly` reads "Tap the button…" — there is nothing to
reword by hand. A browser in which device detection failed gets the wording of a desktop, the same
way it gets the desktop's QR code and buttons. One QR code for several channels is what a page shows
in [hop mode](/docs/reference/configuration#veriqahopmode). Under `QrMode: Unified` the page has no tabs
and uses the wording of the last column. Under `QrMode: UnifiedWithChannels` the QR code sits on the
"Any channel" tab, followed by a tab for each channel, and the wording follows the selected tab: a
channel tab — one QR code and one button — keeps the last column, while the "Any channel" tab, where
the user picks one of several, reads `Scan the QR code you need or tap a messenger button` in place
of its first row. With [`ShowChannelButtonsOnAnyChannelTab`](/docs/reference/configuration#veriqahopmode)
set to `false`, that tab shows the QR code alone and reads `Scan the QR code to sign in`. The page
opens on "Any channel", so a browser with JavaScript turned off shows the wording of that tab.
**The tabs of a page with the "Any channel" tab.** "Any channel" takes a row of the tab strip on its
own; the channel tabs follow, two to a row, and the last of an odd number spans the whole row. A
channel tab whose panel shows that channel's own QR code is labeled with the built-in text `{0} ▦`:
the name of the channel goes into `{0}`, and the mark `▦` is drawn as a QR code icon in the color of
the tab text, so the user sees which tabs hold a QR code. A tab whose panel shows no QR code of its
channel — Email signing in with an emailed link — carries the name alone. The label can be reworded
like any other text: a replacement keeps `{0}`, each `▦` in it becomes the icon, and a replacement
without the mark shows no icon.
**A page of one channel names the channel.** The page fills the `{0}` slot with the icon of the
channel followed by its name — the pair its tab would carry; a channel that has no icon is named by
the name alone. A page of several channels names none.
Each of the ten wordings is a built-in text, so it is reworded like any other: for one application
in its [`AuthPage.Texts` dictionary](#rewording-a-page-element-for-one-application), for the whole
installation in the locale files. Two things to keep in mind for the three texts with a slot:
* a replacement in `AuthPage.Texts` has to carry `{0}`, as every replacement of a text with a slot
does; a translation in a locale file that drops it is shown as is, and the channel then goes
unnamed;
* `{0}` goes in the text, never inside a tag. A wording that puts it into an attribute value —
`` — is shown as plain text, with a
warning in the log (see
[Markup in the title, the instruction and a channel hint](#markup-in-the-title-the-instruction-and-a-channel-hint)).
The `Instruction` of a `ui_config` record replaces the instruction whole — one wording for every
device, with no channel named. A page whose only channel is Email with an address form shows neither
a QR code nor a channel button, yet gets the same one-channel wording; give such a page an `Instruction`
of its own.
### The page wording follows whether the QR code is shown
Strings that name the code or the button go into the markup in **every wording at once**: the
instruction in the three above, the "back to the QR code" link of the Email panel as a pair — "with
QR" and "without QR". They are switched by the same conditions as the blocks they describe: rules
keyed on the root element's `data-qr-visibility`, `data-channel-button-visibility` and `data-device`
attributes — for the pair, the `.veriqa-if-qr` / `.veriqa-if-no-qr` classes. The script of the page
takes no part in the choice. That way the page never promises a QR code or a button it does not show.
This constrains your own CSS (`CustomCssPath`) and script (`CustomJsPath`): a rule
that overrides the `display` of these wordings decouples the text from what the page shows — leaving,
say, "scan the code" on a page with no code. If you hide or show the QR code or the buttons by your own
means, switch the wordings by the same conditions.
The server always generates the QR code, even when the page does not show it: the PNG goes into the
markup as a data URI and stays hidden. Under `Never` this only adds to the response size.
## Channel layout
Where a page shows several channels, each with a QR code of its own, the channels share the page through tabs —
one on screen at a time. The `ChannelLayout` key of the `Veriqa:AuthPageDesign` section puts all of
them on screen at once instead:
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"ChannelLayout": "Grid"
}
}
}
```
| Value | Behavior |
| - | - |
| `Tabs` | One channel at a time: a tab strip, and the panel of the selected tab on screen. Default. |
| `Grid` | Every channel at once: the panels stand side by side, each under the name of its channel and with its own QR code. Meant for a screen read from a distance — a television, a kiosk — where nobody switches a tab. |
Under `Grid`:
* **The card grows with the number of channels.** Its width is the usual one column (420px) plus a
column for every further channel, and never more than the window less the margins the card always
keeps. Panels that do not fit a row wrap onto the next line — down to one per line on a narrow
window.
* **A panel holds what its tab would show:** the QR code, the channel button (subject to
[`ShowChannelButton`](#channel-button-visibility)), the Email form, the channel hint.
* **There are no tabs in the markup.** Each panel is a group labeled by the visible name of its
channel, so a screen reader announces the channels rather than "tab 1 of 3".
The key arranges what the tabs would otherwise hold and nothing else. A page with nothing to arrange
is the same under either value:
* a page of one channel;
* a page laid out as [channel buttons](#channel-buttons) — any device under `ShowQrCode: Never`, a
phone under `DesktopOnly`;
* in [hop mode](/docs/reference/configuration#veriqahopmode), a page that shows one QR code for all its
channels — also where that QR code stands on the "Any channel" tab before the channel tabs
(`QrMode: UnifiedWithChannels`): its tabs stay tabs.
Which channels the page carries is a different setting — `ChannelDisplay:Mode` and the channel set.
The layout applies to the sign-in window and to the page that opens a confirmation. It is set at
the same levels as `ShowChannelButton`: the `ui_config` record (`ChannelLayout` under
`Veriqa:UiConfigurations:Records:`) → the client entry (`ChannelLayout` in its entry of
`Veriqa:OpenIddict:Clients`) → the global section.
**Your own stylesheet.** A rule of `CustomCssPath` that sets the width of `.veriqa-container`
narrows the card of the grid back to one column, and the row turns into a stack. Write it for the
one-column card only: `.veriqa-container:not(.veriqa-container--grid)`.
## Cancel button
The sign-in window carries a **Cancel sign-in** button — the way back to the application for a user
who has changed their mind. It sits below the channels, the status line and the countdown, above
the [footer block](#footer-block-of-the-sign-in-page). The button is **on by default**; the `Enabled`
key of the `Veriqa:AuthPageDesign:Cancel` section switches it off:
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"Cancel": {
"Enabled": false
}
}
}
}
```
What pressing it does:
* **The sign-in ends as declined by the user** — the transaction fails with the reason
`declined_by_user`, the same as a "No" on the web confirmation page.
* **The browser returns to your application** — to the `redirect_uri` of the request, with the
standard `error=access_denied`, the `state` the request carried and the issuer identifier `iss`
([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)), in the response mode it asked for
(`query`, `fragment` or `form_post`). Your application handles it like any other declined sign-in.
An ASP.NET Core client on `AddOpenIdConnect` has to be told where to send the user: without
`AccessDeniedPath` the handler throws on `access_denied` and the return ends in an HTTP 500 — see
[step 5 of the .NET quickstart](/docs/quickstart/dotnet#5-wire-up-the-client-application).
* **A confirmation arriving from the channel at the same moment does not win:** no authorization
code is issued, and the application still receives `access_denied`.
* **The chat where the question waits is told:** when the sign-in was waiting for the user's answer
to a question already shown in a messenger chat, that chat gets the same "Sign-in declined" receipt
a press of the decline button gives. Where the platform can edit a sent message — Telegram, for
one — the receipt replaces the question and its buttons go away; elsewhere — Instagram, Messenger,
WhatsApp and the like — it arrives as a new message and the buttons of the question stay, a later
press being answered with the same receipt. Nothing is sent when the question is not in a chat:
with Email, or when it is asked on the web page. Whatever happens to the receipt, the application
receives `access_denied`.
The button is a plain form, so it works on a page whose script never ran. It disappears from the page
once there is nothing left to cancel — the sign-in has expired, was declined or has failed.
**Where it is shown.** In the sign-in window only. The page that opens a confirmation has no
application to return to, and the page a phone gets after scanning a hop QR code is on another
device — neither carries the button, whatever the setting.
**Levels.** The nearest level that sets the key wins: the `ui_config` record (`Cancel:Enabled`
under `Veriqa:UiConfigurations:Records:`) → the client entry → the global section. To leave
one application without the button, set it in that client's entry:
```json appsettings.json theme={null}
{
"Veriqa": {
"OpenIddict": {
"Clients": [
{
"ClientId": "my-app",
"Cancel": {
"Enabled": false
}
}
]
}
}
}
```
**The text** is the Natural Key `Cancel sign-in` — the key the cancel action of the claims
completion step already uses, so the translations you have apply to the button as well. To reword
the button of the sign-in window for one application, name `Cancel sign-in` in its
[`AuthPage.Texts` dictionary](#rewording-a-page-element-for-one-application).
**After an update.** The button is on by default, so it appears in every deployment that does not
set `"Enabled": false` — nothing has to be configured to get it. If you added a cancel control of
your own through `CustomJsPath`, remove it or switch the built-in button off. A
[renderer of your own](#sign-in-page-renderer) shows no button until it renders the form itself.
**What the button changes in the response of the sign-in window.** The `form-action` directive of
its Content-Security-Policy names the origin of the client's `redirect_uri` next to `'self'` (or, for
an address with a custom scheme, that scheme): a browser applies the directive to the whole
chain of redirects after a form post, and the post of the button ends on the `redirect_uri`. The
response also sets the antiforgery cookie and is sent with `Cache-Control: no-cache, no-store`.
With the button switched off the response is what it was.
## Branding
The client's logo and brand name fill the brand row at the top of the sign-in card in **every preset
but `Minimal`**; the primary color applies **only** in the `Branded` preset:
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"Preset": "Branded",
"PrimaryColor": "#FF5500",
"LogoUrl": "https://your-domain.com/logo.png",
"BrandName": "Acme",
"PrimaryColorByTheme": { "dark": "#FF8A3D" },
"LogoUrlByTheme": { "dark": "https://your-domain.com/logo-dark.png" }
}
}
}
```
* `PrimaryColor` — strictly `#RRGGBB`; an invalid value falls back to a neutral default. It only
colors shared elements (the primary button, focus rings, the spinner).
* `LogoUrl` and `BrandName` together replace the product's own mark. With neither of them set at
any level, the brand row shows the red diamond and `Veriqa`. Setting **either** one replaces that
mark as a whole: a name alone shows no diamond, a logo alone shows no `Veriqa`. An unset logo
therefore does not mean "no brand row" — for a page without one, use the `Minimal` preset. Pages
other than the sign-in window (web confirmation, an expired sign-in, the email pages) show no brand
row at all.
* `BrandName` — the name beside the logo, in place of the product's own wordmark. The two are
resolved **independently**: set either one alone and the page shows what there is — the name
alone, the logo alone, or both. Unlike the logo the name has **no per-theme variant** (an
image cannot repaint itself for a dark page, text takes the page's color), so `BrandNameByTheme`
does not exist. A blank value sets nothing: the name then comes from the level below. The value
is plain text — it is encoded on output — and a localization key (see
[Texts set in configuration](#texts-set-in-configuration-are-localization-keys-too)).
* `PrimaryColorByTheme` and `LogoUrlByTheme` set the value for a single theme (`light`, `dark`).
A theme without a value of its own takes the neighboring shared key (`PrimaryColor`, `LogoUrl`).
With `Theme: Auto` the browser picks the theme, and both values — the light one and the dark
one — travel to the page.
* Messenger brand colors are **never** recolored, in any preset.
**After an update.** A logo or a brand name set while the preset is not `Branded` used to be
ignored and is now shown. A `Branded` page with neither set used to show no brand row and now
shows the product's own mark. Under `Minimal` the brand row is absent from the markup, so a custom
stylesheet or script that looked for it finds nothing.
## Footer block of the sign-in page
`FooterHtml` puts markup of your own at the bottom of the sign-in card — links to a privacy policy
and terms of service, a short legal note, a support contact. The block is shown in **every** preset,
`Minimal` and a custom stylesheet included, and on both pages that render the sign-in window: the
sign-in itself and the page that opens a confirmation. No other page Veriqa generates carries it —
the confirmation pages, the expired-link page and the email sign-in pages have no footer block.
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"FooterHtml": "Privacy · Terms"
}
}
}
```
The block is set in this global section and by a client entry
(`Veriqa:OpenIddict:Clients`), where an application sets a block of its own instead of the global
one. A `ui_config` record does **not** set it: a record is chosen by a request parameter, and what
a request may choose is the look of the page, not markup on it.
**The `Tenant` level.** The setting exists at the tenant level as well, but no reader for that
level is registered out of the box — the integrator writes one. Without it the resolution goes to
the client entry and to this section.
**A blank value sets nothing**, at every level alike. So an application whose entry carries an
empty `FooterHtml` shows the block of the global section: a lower level cannot switch off the block
an upper one sets, it can only replace it. To leave one application without a block, set the
block in the entries of the applications that need it rather than globally.
### What the markup may contain
The value is checked against a strict shape and is refused **whole** when it steps outside it —
nothing is stripped, nothing is repaired:
* **text** — any characters except `<` and `>`. A bare `&` is fine; a bare `>` is not, write it as
`>`;
* **tags** `a`, `span`, `div`, `p`, `strong`, `em`, and the void `br` (` `, ` `, ` ` —
there is no ``). Names of tags and attributes are case-insensitive;
* **attributes** `href`, `target`, `rel` and `class`, each at most once per tag, with the value in
double quotes and free of `"`, `<`, `>`. Anything else — `style`, `id`, any `on*` handler — is
refused, and so are single-quoted and unquoted values;
* **no class name of the page itself**: a `class` is refused when one of its names is `veriqa` or
begins with `veriqa-`, in any letter case and after character references are decoded. A name that
merely starts with the word — `veriqable` — is fine, and so is an empty `class`;
* **every link carries `href` and `target="_blank"`.** The address is judged in the form the browser
reads it — after character references are decoded — and must be `https://…`, `http://…` or a
root-relative `/path`. A protocol-relative `//host`, a `javascript:` or `mailto:` address, a
backslash or a control character inside the address are refused;
* **the tags are balanced**: a closing tag closes the innermost open one, and none stays open at the
end;
* comments, `
**The block is emitted as is — it is not sanitized.** The check above decides whether a value
may go into the page; it does not clean it. What the block says, where its links lead and what
they do to the person who follows them are yours to answer for. Veriqa adds no `rel` of its own
either: the attributes of your links are yours to set.
**Why `veriqa-` class names are refused.** They belong to the markup of the page: its script
reaches its own elements by class (`.veriqa-tab`, `.veriqa-panel`, `.veriqa-tooltip-host`,
`.veriqa-tooltip`), so a block carrying one of them would affect the behavior of the page it sits
on — a `class="veriqa-panel"` inside the block would be hidden by the channel tabs. Name your own
classes with a prefix of your own and style them from your stylesheet (`CustomCssPath`).
**Upgrading with such a class already set.** Earlier versions accepted a `veriqa-` class in this
block; now the value is invalid. Set in this global section, it stops the host from
starting; set in a client entry, it drops that entry — see the table below. Rename the class
before you upgrade.
### What an invalid value does
| Where the value is set | What happens |
| - | - |
| This global section | The host **does not start**, and the message names the setting and the section to correct it in. A configuration reloaded into an already running host keeps the value last read successfully instead |
| A client entry | The entry is dropped from the effective configuration **whole** — that client then resolves every one of its settings, not merely the block, from the level below. The log names the client and the setting |
| A translation in a locale file | The page renders **without** the block, and a warning naming the setting and the language goes into the log. The value stays in force for the other languages |
### Translating the block
The value is a Natural Key like every other text of the page (see
[Texts set in configuration](#texts-set-in-configuration-are-localization-keys-too)): the page looks
it up in the host locale files, and a value no file carries is emitted as is. The translation is
checked against the same shape as the value — a translation that steps outside it leaves the page
without the block.
The context suffix applies too, and with markup it is worth using: the block is a long string, and a
label keeps it from colliding with anything.
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"FooterHtml": "Privacy | authpage"
}
}
}
```
```json ru.json theme={null}
{
"Privacy | authpage": "Конфиденциальность"
}
```
Note that `|` is **reserved** for that label: everything after the **last** `|` is cut off the
text the page shows. Markup that has to contain `|` of its own does not fit this convention — use a
`|` entity or another separator.
## Markup in the title, the instruction and a channel hint
Three texts of the sign-in window take inline markup of your own — a word in bold, a link, a `span`
with a class of yours:
* **the title** — the `Title` of a `ui_config` record, or your wording of the built-in title in
`AuthPage.Texts` or in a locale file;
* **the instruction under it** — the `Instruction` of a record, or your wording of a built-in
[instruction](#the-instruction-follows-both-settings);
* **a channel hint** — an entry of `Veriqa:ChannelDisplay:Hints`.
```json appsettings.json theme={null}
{
"Veriqa": {
"UiConfigurations": {
"Records": {
"brandA": {
"Title": "Sign in to Acme",
"Instruction": "Confirm the sign-in to Acme"
}
}
},
"ChannelDisplay": {
"Hints": {
"whatsapp": "Send the message as it is"
}
}
}
}
```
The value passes the same check as the [footer block](#what-the-markup-may-contain), with a narrower
set of tags — inline ones only:
* **tags** `span`, `strong`, `em`, `a` and the void `br`. `div` and `p` are refused here: the text
already sits inside a heading or a paragraph;
* **attributes** `href`, `target`, `rel` and `class`, under the rules of the footer block — double
quotes, at most once per tag, nothing else;
* **every link carries `href` and `target="_blank"`**, for the same reason: a link that opens in
place ends the sign-in running on the page;
* **no class name of the page itself** — `veriqa` and anything beginning with `veriqa-` are refused;
* **no `img` and no `svg`.** An icon is a `span` with a class of yours, drawn by your own stylesheet
(`CustomCssPath`): ``. Attaching a stylesheet switches the design
preset off — see [External CSS](#external-css).
**A value that fails the check is shown as plain text.** Unlike the footer block, nothing stops the
host and no entry is dropped: the value is judged when the page renders, in the language it renders
in — a translation from a locale file is judged as translated. A value that steps outside the shape
goes onto the page **whole, as text** — the tags are visible as characters — and a warning goes into
the log. The warning names the element and the language (for a hint — the channel type); the value
itself is not logged.
**Plain text is read as HTML text.** A value with no tags passes as is, and the browser reads its
character references: `Terms & Conditions` shows "Terms & Conditions". Write a literal `<` or `>`
as `<` and `>` — a bare one fails the check, and the value is then shown as written, with the
warning above.
**The browser tab gets the title without its tags.** The `` of the document carries the text
of the title alone, with a ` ` turned into a space.
The same holds on both pages that render the window — the sign-in itself and the page that opens a
confirmation. A renderer of your own receives these values raw — see
[Sign-in page renderer](#sign-in-page-renderer).
## Instruction after the scan
In [hop mode](/docs/reference/configuration#veriqahopmode) the page learns that the user has continued on
the phone before the confirmation arrives: the QR code was scanned, the page opened on the phone, the
transition into the messenger started — the phone page starts it by itself, and its button starts it
again on a tap. That last step says the browser began to open the app, not that the app opened. The
status line names each such step. The scan names what follows on the phone: "QR code scanned.
Continue in Telegram." for a messenger, "QR code scanned. Continue on your phone to confirm by email."
for Email, and "QR code scanned. Continue on your phone." for the QR code shared by every channel. When the phone page opens the app by itself, the last two steps
arrive together, and the status line shows only "Opening …" instead of flashing both. On the first of
them the page takes the
channel choice away — the tabs, the QR codes and the channel buttons, with the instruction under the
title that describes them — and shows an instruction in their place: by default "Continue on your
phone and confirm in the app that has opened." The subject of a confirmation, the status line and the
countdown stay. Below the instruction is the "Choose another way" button: it brings the channel
choice back on the same page, with the same sign-in — for a user who scanned with the wrong device
or changed their mind. The next step reported from a phone, the same phone included, shows the
instruction again. Once a channel has answered the start — its bot sent the question into the chat —
the button disappears from the page for good: the sign-in is bound to that channel's account from
then on, and another channel could only end in a refusal. On a sign-in, where the status line then
says "You have moved to …", the instruction disappears together with the channel choice: the page
keeps the title, the status line, the countdown and "Cancel sign-in". A page that fell back to
polling does not hear the answer and keeps the button. Reloading the page brings the channel choice
back as well; once the sign-in expires, the instruction disappears and the restart link appears.
`HopInstructionHtml` replaces the core's text with markup of your own:
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"HopInstructionHtml": "
Check your phone: confirm in the messenger that has just opened.
"
}
}
}
```
The setting follows the [footer block](#footer-block-of-the-sign-in-page) in every rule but one: the
same levels (this global section, a client entry, the tenant — not a `ui_config` record), a blank
value sets nothing, the same allowed shape and the same outcome of an invalid value, and the
value is a Natural Key. The exception is a translation outside the shape: the page then shows the
core's own text rather than no block, because the block sits where the channel choice was, and an
empty one would leave the user without a word on what to do next. A warning naming the setting and the
language goes into the log.
The "Choose another way" button is not part of the setting: it sits beside your markup, not inside
it, and its caption is a text of the sign-in window like any other.
The setting has no style of its own. Style the block from your stylesheet (`CustomCssPath`) through
its `veriqa-hop-instruction` class; the page's own rules set only its text color, size and spacing.
## CSS variables
The pages are styled through CSS variables prefixed `--veriqa-*`, which you can override in your
own CSS. The main names are below; the full set is declared in the package stylesheet and also
includes the `--veriqa-spacing-*` scale and the colors of the service-page badges:
```css theme={null}
:root {
/* Primary — neutral canon per theme (light: #111827, dark: #f9fafb);
the client color applies only in the Branded preset */
--veriqa-primary: #111827;
--veriqa-on-primary: #ffffff;
/* Surfaces and background (light theme) */
--veriqa-bg: #ffffff;
--veriqa-surface: #f9f9fa;
--veriqa-surface-variant: #e2e2e3;
--veriqa-surface-container: #eeeeef;
/* Text */
--veriqa-text: #1a1c1d;
--veriqa-muted: #4c4546;
--veriqa-soft: #767071;
/* Semantic colors */
--veriqa-success: #16a34a;
--veriqa-error: #dc2626;
--veriqa-warning: #eab308;
/* Radii, typography, animation */
--veriqa-radius: 8px;
--veriqa-radius-lg: 12px;
--veriqa-font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
--veriqa-font-mono: "JetBrains Mono", Consolas, monospace;
--veriqa-transition-fast: 160ms ease;
}
```
## External CSS
Attach your own stylesheet via `CustomCssPath` — it loads **after** the base styles and can
override any variable. When `CustomCssPath` is set, the preset is ignored: your CSS fully defines
the look.
The stylesheet is attached to **every** page Veriqa generates: the sign-in window, the sign-in
confirmation page, the expired-link page and the email sign-in pages. Your CSS styles them all
alike — there is no separate setting for the service pages.
The expired-link page always offers one action. When the application sets `InitiateLoginUri` —
its own address for starting a sign-in (the `Client.InitiateLoginUri` key of the
[configuration reference](/docs/reference/configuration)) — the page links to it as "Start the sign-in
again"; the address must be an absolute `https` URL. Without one the page shows a "Close" button
instead. The texts of this page can be replaced like the texts of the sign-in window — see
[Rewording a page element for one application](#rewording-a-page-element-for-one-application). The page belongs to a sign-in only:
a link of the sign-in flow that names an action confirmation leads to the neutral "This link is no
longer available" page instead.
```json appsettings.json theme={null}
{
"Veriqa": {
"AuthPageDesign": {
"CustomCssPath": "/css/veriqa-custom.css",
"CssSriHash": "sha384-..."
}
}
}
```
Set `CssSriHash` (Subresource Integrity) if styles load from an external address — external
resources on the sign-in page are disallowed by default.
## Per-client UI configurations (ui\_config)
Beyond the global look, you can define **named UI configurations** and bind them to a specific
OIDC client. The catalog of records is the `Records` map of the `Veriqa:UiConfigurations` section
("record code → look"), so the full path of a record is `Veriqa:UiConfigurations:Records:`;
a record overrides the global look for its code.
```json appsettings.json theme={null}
{
"Veriqa": {
"UiConfigurations": {
"Records": {
"brandA": {
"Preset": "Branded",
"PrimaryColor": "#FF5500",
"LogoUrl": "https://your-domain.com/logo.png",
"BrandName": "Acme",
"Title": "Sign in",
"TtlSeconds": 300
}
}
},
"OpenIddict": {
"Clients": [
{
"ClientId": "my-app",
"DefaultUiConfig": "brandA",
"AllowedUiConfigs": [ "brandA" ]
}
]
}
}
}
```
* `DefaultUiConfig` — the record code applied by default for the client.
* `AllowedUiConfigs` — codes the client is allowed to request. A code from this list is valid even
without a catalog record — the global look is then used.
## Localization
The sign-in page uses the **Natural Keys** approach: the localization key is the English source
text, and `en` is the base language (it needs no translation file). The supported-language
registry is data-driven and built from the locale files **your host** ships: JSON dictionaries
`{lang}.json` in `wwwroot/locales` (the path is configurable via
`Veriqa:Channels:Localization:LocalesPath`) plus the base `en`. Out of the box the page renders in
English; to add a language, put its `{lang}.json` into the locales directory — no code changes
required. You do not have to translate Russian and Chinese at all: they are shipped, see
[Ready-made translations](#ready-made-translations) below. The directory itself is optional: a host
without it logs `Locale files directory not found — channel messages are sent in the base language.`
on startup, and that warning is expected.
### Ready-made translations
The whole catalog — 106 keys, everything a user reads while authenticating — is published as a
content-only package, `Veriqa.Core.ChannelLocales`. It carries `en.json`, `ru.json`, `zh.json` and
`pseudo.json` (a layout-test locale rather than a production language: it is detectable through an
explicit `Accept-Language`, but refused as `DefaultLanguage`).
```xml PackageReference theme={null}
```
**The package by itself switches no language on.** Its build targets lay the files down under
`veriqa-channel-locales/` beside your entry assembly, deliberately **not** in `wwwroot/locales`:
the core catalog must never overwrite yours — the two are meant to be compared, not merged.
Until one of the two steps below is taken, the host still renders in the base language.
Two ways to use it:
* **copy** the files you need out of `veriqa-channel-locales/` into your own locales directory. They
are yours from then on, and you are free to edit any wording in them;
* **point at it**: set `Veriqa:Channels:Localization:LocalesPath` to the directory the package lays
down. A relative path is resolved from the host's **content root**, so set a path that is right
for the way you run the application — or an absolute one. Nothing there is yours to edit: the next
version of the package replaces those files whole.
The package is also the key list for a host that keeps a catalog of its own: `en.json` is the
authoritative set of keys, and a key missing from your files quietly falls back to English for the
user.
### Which language a user gets
The sign-in page and the messages in the channel take their language from **different** sources, and
the two legitimately differ.
**The sign-in page** follows the request: the OIDC `ui_locales` parameter first — an explicit choice
made on the relying party's side — and the browser's `Accept-Language` after it. A value matching no
language of the registry falls back to the default language:
```json appsettings.json theme={null}
{
"Veriqa": {
"Localization": {
"DefaultLanguage": "en"
}
}
}
```
`DefaultLanguage` is validated at startup: the value must be in the registry (the host's locale
files + `en`), otherwise the application does not start. For example, `"DefaultLanguage": "ru"`
requires the host to ship `wwwroot/locales/ru.json`.
The language the page was rendered in travels **on the transaction**, and every later page and
message of that sign-in falls back to it — including the `form_post` auto-posting page that delivers
the authorization response, which renders in that language instead of reading the browser header
again. `Accept-Language` with the default language stays the fallback for responses that never had a
transaction, such as errors returned to the `redirect_uri`. A confirmation created
[server to server](/docs/reference/confirmation-api) has no page at all — what travels there is the
`locale` field of the request.
**A message inside a channel** follows the recipient instead. A channel that knows the user's profile
language supplies it with every event, and it outranks everything the browser said: a user whose
Telegram is in Russian gets a Russian prompt from a page rendered in English. A channel that supplies
nothing falls back to the locale the transaction carries, and failing that to the base language.
| Channel | Supplies the recipient's language |
| - | - |
| Telegram | yes — the language Telegram reports for the user |
| MAX | yes — the `user_locale` of the update |
| WhatsApp | no — the transaction locale is used |
| Email | no — see below |
**Email is a third case**, because its texts are not read inside a messenger. The sign-in email goes out
before anyone has answered, so there is no profile to ask: it is worded in the language of the request
that asked for the email — that browser's `Accept-Language` — falling back to the locale of the
transaction. The confirmation page the magic link opens does the opposite: it prefers the
transaction's locale over the `Accept-Language` of the mail client's browser, so that page matches the
sign-in window the user started in.
One installation may therefore speak two languages in one sign-in, and that is the intended behavior
rather than a misconfiguration. What it does require is that every language involved is present in the
locale files: a language nobody translated degrades to the base one silently.
### Channel and email texts — the same locale files
The same directory drives more than the sign-in page: nearly everything the user sees **in the
messenger and in the email** — button captions, the status after their answer, the visible lines of the
email — comes from there. One rule: the key is the English text, the value is what the user sees. To reword anything,
add the key with your value to the locale file of that language — no code involved.
The main keys:
| Key (also the English text) | Where it shows |
| - | - |
| `Confirm ✅` · `Decline ❌` | buttons under the confirmation request (Telegram, WhatsApp, MAX) |
| `Sign-in confirmed ✅` · `Sign-in declined ❌` | the status after the user answers **when signing in**, in the same channels |
| `Sign-in confirmed ✅ Tap Back to return to the sign-in page.` | the confirmed status in the same channels when the sign-in was **started on a phone or a tablet**: the channel opened on that same device, and the text tells the user how to get back |
| `Sign-in confirmed` · `Sign-in declined` · `Sign-in confirmed. Tap Back to return to the sign-in page.` | the same three for third-party channels when signing in — a platform need not render emoji |
| `Something went wrong ⚠️ Go back to the sign-in page and start again.` · `Something went wrong. Go back to the sign-in page and start again.` | an error while handling the answer — the channel's own text, independent of the transaction type (the emoji variant in the same channels, the plain one in third-party channels) |
| `Too many attempts. Wait a minute and start the sign-in again.` | the answer when the per-user attempt budget is spent — it advises waiting rather than starting over, which a spent budget would refuse again |
| `Sign-in time expired ⌛ Go back to the sign-in page and start again.` · `Sign-in time expired. Go back to the sign-in page and start again.` · `Sign-in time expired. Start the sign-in again from the application.` | the status **when signing in**, once the confirmation window has passed — in the same channels, in third-party channels and on the Email confirm page respectively |
| `Sign-in time expired ⌛` | the same status on the pages Veriqa itself serves |
| `Confirmed ✅` · `Declined ❌` · `Time expired ⌛` | the receipt of a confirmed action, not of a sign-in; the text is neutral and the same on every surface |
| `Confirm sign-in` | the confirmation request without initiator context |
| `Confirm sign-in to "{app}"` · `… — request from {browser} on {os}` · `… on {os}, {region}` | the request with initiator context; a translation must keep the `{slot}` set — on a mismatch the English text is used |
| `Sign in to {app}` | the subject of the Email channel's email |
| `Confirm sign-in` · `You requested sign-in via Veriqa.` · `The request came from:` · `Press the button below to confirm sign-in.` · `The link is valid until:` · `Approve sign-in` · `To confirm, open the following link:` · `Or scan the QR code to finish signing in on another device:` · `Direct link:` · `Do not forward this mail: the link grants sign-in access to your account.` · `If you did not request sign-in, simply ignore this mail.` | every visible line of the sign-in email: the heading, the intro, the label of the initiator details, the hint above the button, the label of the validity time, the button caption, the hint above the plain link, the QR code block captions, the footer. `Confirm sign-in` is the same key the confirmation request uses |
**Which of the two confirmed texts a user gets is decided by the message template, not by the
locale file.** The receipt of a sign-in is the message kind `outcome-receipt-confirmed`, and its
template narrows by the device the sign-in was started on — the `ByDevice` group, with the values
`desktop`, `mobile`, `tablet` and `unknown`. The product ships the short text for every device and
the text with the way back under `ByDevice:mobile` and `ByDevice:tablet`. The locale file only
translates each of them. If you set the receipt of a sign-in in the configuration of the host
without `ByDevice`, phones and tablets still get the shipped text with the way back — set your
own `ByDevice` steps as well. A wording set for one application, tenant or `ui_config` record
answers for every device by itself. The addresses and an example:
[Configuration reference](/docs/reference/configuration#veriqamessagetemplates).
These are **installation-wide product defaults**: locale files are a host asset, one set per
process. Per-tenant wording is not configured here: the texts of the pages Veriqa renders are
reworded for one application or tenant by [a dictionary of its own](#rewording-a-page-element-for-one-application).
**Every visible phrase of the shipped sign-in email is a localization key.** The body of the email —
both the HTML and the plain-text part — holds no wording of its own: each phrase is a
localized-text slot, so an email in the recipient's language is in that language throughout, and
translating the email is editing a locale file. A phrase does not carry a substitution inside it:
the value sits beside its label — `The link is valid until:` followed by the time,
`The request came from:` followed by the browser, the system and the region — and the name of the
application has a line of its own under the heading. The subject `Sign in to {app}` is the one text
translated as a whole. The same holds for the claims-completion emails, `claim-code-link-mail`
included.
A translated phrase is limited to 128 characters, like any value that comes from outside the
product; a longer one is cut. Values Veriqa computes itself — the link, the code, the validity
time — are substituted in full, whatever their length. If you
[set your own email body](/docs/guides/channels#your-own-mail-body), split a long sentence into
several slots rather than one.
### One phrase, two translations: the context suffix
The key IS the English text, so one phrase yields one translation per language. When the same
English phrase must translate **differently** in different places (one channel worded unlike the
rest, say), the key carries a context label after `|`:
```json ru.json theme={null}
{
"Sign-in declined ❌": "Вход отклонён ❌",
"Sign-in declined ❌ | telegram": "Вход в Telegram отклонён ❌"
}
```
What to know:
* **the label is metadata — the user never sees it.** With no translation available, the part of
the key **before the last** `|` is shown, i.e. the clean English text, not `… | telegram`;
* **never map a key to itself** (`"X | telegram": "X | telegram"`) — that is the one way to put the
label in front of a user;
* **`|` is reserved** for the label: ordinary UI text must not contain it;
* **add a context only on a real divergence.** By default one phrase is one key with no suffix;
there is no value in contexts whose translations coincide.
### Texts set in configuration are localization keys too
The texts a `ui_config` record sets go through the **same** resolution as the built-in ones: the
configured value IS a Natural Key. There is no such thing as an unlocalizable text in the product.
| `ui_config` setting | What it sets |
| - | - |
| `Title` | the sign-in window title |
| `Instruction` | the on-page instruction of the sign-in window |
| `BrandName` | the brand name in the brand row of the sign-in window, in every preset but `Minimal` |
**The confirmation text is set by a message template, not by a field of the record.** The
wording of the confirmation request is a **message**, and it is set by the ladder of its template:
`Veriqa:MessageTemplates:confirmation-prompt:Templates` (how the key is built and which levels it has
— in the [Configuration reference](/docs/reference/configuration#veriqamessagetemplates)). The rule of
this section applies to it in full: the text you set is a Natural Key just the same and does not
bypass localization. If one application needs a wording of its own, set it in that client's entry
(the `Application` level) rather than globally.
What this means in practice:
* **a value with no locale entry is shown as is.** The key IS the text, so a miss breaks
nothing: the user sees exactly the string you set, merely untranslated. The base language
needs no entry at all;
* **to have the text translated, add its key to your locale files** — the same
`wwwroot/locales/{lang}.json` as every other text. The locales directory is a host asset, and the
host is you: the keys of your `ui_config` records go in there alongside the product ones.
**How to set a value that is meant to be translated.** There is no separate marker for it — use
the regular context suffix (above): the setting holds a ` |