Authentication and API keys
Give each caller an API key with a user, roles and scopes, and know exactly what Alvo answers when a key is missing, wrong or too narrow.
Before you start
Section titled “Before you start”- The stack from Run your own descriptor, serving
examples/help-desk, run from itsalvo-help-deskdirectory withCOMPOSE_FILEand that page’s secrets exported in this shell. - This page adds a fourth key,
reader(rolesagent,authenticated; scope*:readonly), and also usesagentand the stack’s owndemokey.
The responses below were captured from a real host when the site was built, so your ids will differ.
1. Give a caller a key
Section titled “1. Give a caller a key”A key is configuration, not data: the host reads a fixed list from Alvo:Auth:DevKeys, and each entry names the user
the key acts as, the roles it holds and the scopes it may use. This build has no endpoint that issues or revokes keys,
so treat it as a development mechanism: the secret lives in the process’s configuration, and when it is passed as an
environment variable it is readable from a process listing and from docker inspect. Until a real issuance path
lands (#36) these keys are the only way to call a standalone host,
so generate every secret, never reuse one across keys or environments, and prefer a configuration source that does
not show up in docker inspect, such as a mounted file or a secret store.
-
Replace
docker-compose.override.ymlwith this one. It keeps everything the first one sets, addsreader, and gives thedemokey a third role,inspector, which section 2 uses:docker-compose.override.yml # Saved as docker-compose.override.yml, it replaces the one from "Run your own descriptor" and keeps everything# that one sets. Key 3, reader, may read every entity and write none: its scopes hold "*:read" only. The quick# start's demo key gets a third role, inspector, as in the repository's own stack; help-desk does not declare it.name: alvo-help-deskservices:alvo:environment:Alvo__DescriptorPath: /alvo/descriptor.jsonAlvo__Auth__DevKeys__0__Roles__2: inspectorAlvo__Auth__DevKeys__1__KeyId: agentAlvo__Auth__DevKeys__1__Secret: ${ALVO_AGENT_KEY_SECRET:?set ALVO_AGENT_KEY_SECRET before starting the stack}Alvo__Auth__DevKeys__1__User: 3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01Alvo__Auth__DevKeys__1__Roles__0: agentAlvo__Auth__DevKeys__1__Roles__1: authenticatedAlvo__Auth__DevKeys__1__Scopes__0: "*:read"Alvo__Auth__DevKeys__1__Scopes__1: "*:write"Alvo__Auth__DevKeys__2__KeyId: adminAlvo__Auth__DevKeys__2__Secret: ${ALVO_ADMIN_KEY_SECRET:?set ALVO_ADMIN_KEY_SECRET before starting the stack}Alvo__Auth__DevKeys__2__User: 8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02Alvo__Auth__DevKeys__2__Roles__0: adminAlvo__Auth__DevKeys__2__Roles__1: authenticatedAlvo__Auth__DevKeys__2__Scopes__0: "*:read"Alvo__Auth__DevKeys__2__Scopes__1: "*:write"Alvo__Auth__DevKeys__3__KeyId: readerAlvo__Auth__DevKeys__3__Secret: ${ALVO_READER_KEY_SECRET:?set ALVO_READER_KEY_SECRET before starting the stack}Alvo__Auth__DevKeys__3__User: 5a1d0c3e-2f4b-4a6c-9d8e-7f6a5b4c3d03Alvo__Auth__DevKeys__3__Roles__0: agentAlvo__Auth__DevKeys__3__Roles__1: authenticatedAlvo__Auth__DevKeys__3__Scopes__0: "*:read"volumes:- ./help-desk.alvo.json:/alvo/descriptor.json:ro -
Download it, generate the new secret and recreate the
alvocontainer:Terminal curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/authentication.compose.override.ymlexport ALVO_READER_KEY_SECRET="$(openssl rand -hex 16)"docker compose up -d --wait --force-recreate alvoWhile this override is in place, every compose command reads it, so keep
ALVO_READER_KEY_SECRETexported too. A secret shorter than 32 characters is refused at startup, and the container does not come back.openssl rand -hex 16produces exactly 32. -
A request carries the key in the
X-Alvo-Api-Keyheader, as<keyId>.<secret>:Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Printer on fire"}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155168489190"Location: /api/tickets/e476759e-194f-4007-bce3-af7039cdf070{"id": "e476759e-194f-4007-bce3-af7039cdf070","title": "Printer on fire","status": "open"}
Every setting of a key:
| Setting | What it decides |
|---|---|
KeyId | The public half of the key. Unique, and without a ., because the header is split at the first .. |
Secret | The private half, at least 32 characters. Generate it; never choose it. |
User | The id the caller acts as: what a rule reads as @user.id, and what the audit columns record. Set it: a key without one has no identity, so a rule that reads @user.id refuses it with a 403 and the audit columns record null. |
Roles | What a rule tests, as in 'agent' in @user.roles. Each role must be declared in the descriptor’s auth.roles or be built in (anon, authenticated, admin). authenticated is not added for you: list it. |
Scopes | Which entities the key may read or write, as <entity|*>:<read|write>. |
Tenant | The one tenant the key acts in; see Multi-tenancy. |
ExpiresAt | The instant after which the key is refused. Unset, it never expires. |
In an environment variable each setting is indexed, as in the override above: Alvo__Auth__DevKeys__3__Roles__0.
The full list is under Alvo:Auth.
2. A request without a usable key
Section titled “2. A request without a usable key”Alvo tells three cases apart: no key, a key that cannot be used, and a key that may not do this.
-
No key is not a 401. A request without
X-Alvo-Api-Keyis an anonymous caller, who holds only theanonrole, and the rules decide what it gets. The help-desklistrule asks forauthenticated, so the page is empty, though the ticket from step 1 exists:Request curl -sS -X GET http://localhost:8080/api/tickets200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8{"items": [],"next": null,"count": null}A rule that reads
@user.idrefuses an anonymous caller with 403forbiddeninstead; see Access rules. -
A key that cannot be used is a 401. Here the secret is wrong:
Request curl -sS -X GET http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.this-is-not-the-secret-of-this-key"401 Unauthorized
Response HTTP/1.1 401 UnauthorizedContent-Type: application/problem+json{"type": "https://alvo.dev/errors/unauthenticated","title": "Unauthorized","status": 401,"detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested."} -
The override gave the
demokey the roleinspector, whichhelp-deskdoes not declare. One undeclared role is enough for the whole key to authenticate nothing:Request curl -sS -X GET http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET"401 Unauthorized
Response HTTP/1.1 401 UnauthorizedContent-Type: application/problem+json{"type": "https://alvo.dev/errors/unauthenticated","title": "Unauthorized","status": 401,"detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested."}The answer is the same, word for word, for an unknown key, a wrong secret, an expired key, a key with an undeclared role or no role at all, and a tenant header the key was not issued for. That is deliberate: a caller learns nothing about which keys exist. The host’s configuration is where to look.
3. Narrow a key with scopes
Section titled “3. Narrow a key with scopes”Scopes belong to the key and are checked before any rule. read covers list and get; write covers create,
update and delete, and does not include read. A key with no scopes may do nothing on the Data API.
-
readerholds*:readonly, so its write is refused before Alvo looks at a rule or a row:Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Printer on fire"}'403 Forbidden
Response HTTP/1.1 403 ForbiddenContent-Type: application/problem+json{"type": "https://alvo.dev/errors/out-of-scope","title": "Forbidden","status": 403,"detail": "The presented API key's scopes do not permit this operation. Grant the key the scope it needs."} -
The same key reads, within what the rules allow its roles:
Request curl -sS -X GET http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET"200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8{"items": [{"id": "e476759e-194f-4007-bce3-af7039cdf070","body": null,"created_at": "2026-10-11T11:38:36.848919+00:00","created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","estimate_cost": null,"estimate_hours": null,"hourly_rate": null,"priority": "normal","status": "open","title": "Printer on fire","updated_at": "2026-10-11T11:38:36.848919+00:00","updated_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01"}],"next": null,"count": null}
A request has to pass both the key’s scopes and the entity’s rules. Use scopes to limit what a credential may ever do, such as a key for a reporting job, and rules to decide what a user may do to which rows.
Scopes govern the Data API only. The Management API admits a caller by the
descriptor’s access block and the key’s roles alone, so a key scoped to
one entity still reaches management when its roles match an access level.
People sign in to the dashboard
Section titled “People sign in to the dashboard”The dashboard at /admin does not take API keys. People sign in with an email address and a password that Alvo
holds; this build has no other sign-in provider.
- The first administrator comes from configuration:
Alvo__Admin__BootstrapEmail, andAlvo__Admin__BootstrapPasswordFile, the path of a mounted file that holds the password. The quick start’s compose file sets both, and turnsALVO_ADMIN_PASSWORDinto that file. Passing the password itself inAlvo__Admin__BootstrapPasswordis refused at startup, because an environment variable shows up in a process listing and indocker inspect. The account is created once; changing the file later does not change its password. - Everyone else is created by an administrator on the dashboard’s Access screen, which issues a set-password
link. A link works once, for 24 hours by default (an embedded host can change this through
DataProtectionTokenProviderOptions.TokenLifespan), and the person then signs in as usual. - A password is 15 to 128 characters long and must not contain the email address (or the part before
@, once that is three characters or more) it signs in with. There are no composition rules.
A dashboard session works only in the dashboard: it never turns a signed-in person into a Data API caller. An API key
whose User is the bootstrap administrator’s id is admitted to the Management API as an administrator, whatever the
access block says. The admin dashboard covers the screens.
Your own users, in an embedded host
Section titled “Your own users, in an embedded host”When Alvo is embedded in your ASP.NET Core app, your users already sign in to your app. Resolve them in your own
endpoints and pass an AlvoContext with their id, roles and tenant to IAlvoData; Alvo’s rules then apply to them
exactly as they do to a key. Use your own authentication shows how.
Never read a credential from a cookie. Alvo:Auth:HeaderName and Alvo:Auth:TenantHeaderName may name any header
except Cookie: a browser attaches cookies to cross-site requests by itself, which would make every Alvo route a
cross-site request forgery target, so the host refuses that configuration at startup.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 401 | unauthenticated | A key was sent and cannot be used: unknown, wrong secret, expired, a role the descriptor does not declare, no role, or an X-Alvo-Tenant the key was not issued for. | Check the key against the host’s configuration and the descriptor’s auth.roles. | every host |
| 403 | out-of-scope | The key’s scopes do not cover this entity and operation. | Grant the key the scope it needs, such as tickets:write. | every host |
| 403 | forbidden | No key was sent, and the rule for this operation reads @user.id. | Send a key. | every host |
If the container does not come back after you change a key, the end of docker compose logs alvo names the key and
the setting: an empty or short Secret, a duplicate KeyId, a KeyId with a ., or a scope that does not parse.
Reference
Section titled “Reference”- Configuration:
Alvo:Auth. - Descriptor keys:
auth.roles,auth.providers,access. - Problem types:
unauthenticated,out-of-scope,forbidden. - Where scopes and rules are each decided:
docs/architecture/data-api.md.
Access rules: decide, per operation, which callers may reach which rows.