Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

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.

  • The stack from Run your own descriptor, serving examples/help-desk, run from its alvo-help-desk directory with COMPOSE_FILE and that page’s secrets exported in this shell.
  • This page adds a fourth key, reader (roles agent, authenticated; scope *:read only), and also uses agent and the stack’s own demo key.

The responses below were captured from a real host when the site was built, so your ids will differ.

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.

  1. Replace docker-compose.override.yml with this one. It keeps everything the first one sets, adds reader, and gives the demo key 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-desk
    services:
    alvo:
    environment:
    Alvo__DescriptorPath: /alvo/descriptor.json
    Alvo__Auth__DevKeys__0__Roles__2: inspector
    Alvo__Auth__DevKeys__1__KeyId: agent
    Alvo__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-5d6e7f8a9b01
    Alvo__Auth__DevKeys__1__Roles__0: agent
    Alvo__Auth__DevKeys__1__Roles__1: authenticated
    Alvo__Auth__DevKeys__1__Scopes__0: "*:read"
    Alvo__Auth__DevKeys__1__Scopes__1: "*:write"
    Alvo__Auth__DevKeys__2__KeyId: admin
    Alvo__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-9f0b1c2d3e02
    Alvo__Auth__DevKeys__2__Roles__0: admin
    Alvo__Auth__DevKeys__2__Roles__1: authenticated
    Alvo__Auth__DevKeys__2__Scopes__0: "*:read"
    Alvo__Auth__DevKeys__2__Scopes__1: "*:write"
    Alvo__Auth__DevKeys__3__KeyId: reader
    Alvo__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-7f6a5b4c3d03
    Alvo__Auth__DevKeys__3__Roles__0: agent
    Alvo__Auth__DevKeys__3__Roles__1: authenticated
    Alvo__Auth__DevKeys__3__Scopes__0: "*:read"
    volumes:
    - ./help-desk.alvo.json:/alvo/descriptor.json:ro
  2. Download it, generate the new secret and recreate the alvo container:

    Terminal
    curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/authentication.compose.override.yml
    export ALVO_READER_KEY_SECRET="$(openssl rand -hex 16)"
    docker compose up -d --wait --force-recreate alvo

    While this override is in place, every compose command reads it, so keep ALVO_READER_KEY_SECRET exported too. A secret shorter than 32 characters is refused at startup, and the container does not come back. openssl rand -hex 16 produces exactly 32.

  3. A request carries the key in the X-Alvo-Api-Key header, 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 Created
    Content-Type: application/json; charset=utf-8
    ETag: "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:

SettingWhat it decides
KeyIdThe public half of the key. Unique, and without a ., because the header is split at the first ..
SecretThe private half, at least 32 characters. Generate it; never choose it.
UserThe 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.
RolesWhat 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.
ScopesWhich entities the key may read or write, as <entity|*>:<read|write>.
TenantThe one tenant the key acts in; see Multi-tenancy.
ExpiresAtThe 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.

Alvo tells three cases apart: no key, a key that cannot be used, and a key that may not do this.

  1. No key is not a 401. A request without X-Alvo-Api-Key is an anonymous caller, who holds only the anon role, and the rules decide what it gets. The help-desk list rule asks for authenticated, so the page is empty, though the ticket from step 1 exists:

    Request
    curl -sS -X GET http://localhost:8080/api/tickets

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "items": [],
    "next": null,
    "count": null
    }

    A rule that reads @user.id refuses an anonymous caller with 403 forbidden instead; see Access rules.

  2. 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 Unauthorized
    Content-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."
    }
  3. The override gave the demo key the role inspector, which help-desk does 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 Unauthorized
    Content-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.

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.

  1. reader holds *:read only, 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 Forbidden
    Content-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."
    }
  2. 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 OK
    Content-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.

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, and Alvo__Admin__BootstrapPasswordFile, the path of a mounted file that holds the password. The quick start’s compose file sets both, and turns ALVO_ADMIN_PASSWORD into that file. Passing the password itself in Alvo__Admin__BootstrapPassword is refused at startup, because an environment variable shows up in a process listing and in docker 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.

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.

StatusProblem typeWhenFixReturned by
401unauthenticatedA 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
403out-of-scopeThe key’s scopes do not cover this entity and operation.Grant the key the scope it needs, such as tickets:write.every host
403forbiddenNo 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.

Access rules: decide, per operation, which callers may reach which rows.