Test Drive Kliento
This hands-on guide demonstrates how to set up a complete Kliento authentication system locally. You’ll build both client and server components, configure your domain, obtain token bundles, and make authenticated requests between them—all on your own computer. Since Kliento is a small extension to the VeraId protocol, most of this exercise involves setting up VeraId Authority to issue the necessary credentials.
We’ll demonstrate how clients can leverage existing workload identities (like those from GitHub Actions or GCP service accounts) to obtain VeraId credentials. Whilst VeraId Authority is technically optional—clients could use long-lived private keys to sign credentials directly—using VeraId Authority simplifies credential management and is the recommended approach for workload authentication.
For educational purposes, we use low-level tools like curl
rather than our existing high-level tooling to help you understand what’s happening. This, coupled with the fact that we’ll be implementing both client and server sides simultaneously, makes this walkthrough appear more complex than a typical Kliento integration would be in production.
If you get stuck at any point, please post a question on the r/VeraId Reddit sub.
Prerequisites
- A domain name you control.
- Docker Compose.
- Curl.
jq
.
Please note that the commands in this tutorial have been tested on Linux and macOS. If you’re using Windows, you’ll need to adjust the commands accordingly (e.g. use curl.exe
instead of curl
).
Lastly, we’ll be creating new files, which you may want to do in a new temporary directory.
1. Enable DNSSEC on your domain
Skip this step if you’re using a domain name that is already DNSSEC-enabled.
If your DNS hosting provider is the same as your registrar, the process may be as straightforward as ticking a box to enable DNSSEC. Otherwise, you’ll have to copy the DNSSEC configuration from your DNS hosting provider (e.g. Cloudflare) to your registrar (e.g. GoDaddy). Either way, the process is entirely provider-specific, so we recommend you consult the relevant documentation if it isn’t obvious.
You can use DNSSEC Analyzer to verify that your domain is correctly configured. Since it may take a few minutes for the DNS changes to propagate, you may want to continue with the rest of the guide and check back later.
2. Implement the server
We’ll implement a trivial Hono server that verifies Kliento token bundles and outputs the identifier of the client, as well as the claims in the token.
Save the following code in a file called server.js
:
import { serve } from "@hono/node-server";
import { TokenBundle } from "@veraid/kliento";
import { Hono } from "hono";
const app = new Hono().get("/", async (context) => {
const audience = context.req.url;
const authHeader = context.req.header("Authorization");
const tokenBundle = TokenBundle.deserialiseFromAuthHeader(authHeader);
const result = await tokenBundle.verify(audience);
return context.json(result);
});
serve({ fetch: app.fetch, port: 3000 });
The server above exposes a GET /
endpoint that accepts an Authorization
header with a Kliento token bundle, and requires the audience
to be set to the URL of the request.
Next, create the package.json
file with the following content:
{
"name": "kliento-server",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@hono/node-server": "^1.14.1",
"@veraid/kliento-verifier": "^1.0.3"
}
}
We’re going to deploy it in a Docker container, so create the Dockerfile
file with the following content:
FROM node:22.14.0-slim
WORKDIR /opt/kliento-server
COPY . ./
RUN npm install
CMD ["node", "server.js"]
EXPOSE 3000
We’ll deploy this server in the next step when we also deploy VeraId Authority.
3. Deploy VeraId Authority
You’re going to use Docker Compose to deploy a local instance of VeraId Authority, along with its backing services (e.g. MongoDB), and a mock OAuth2 server, to emulate a third-party identity provider like GCP (https://accounts.google.com
).
In an eventual production deployment, you may actually prefer to use our upcoming VeraId Authority-as-a-Service offering or deploy a serverless infrastructure with an official Terraform module (e.g. relaycorp/veraid-authority/google
for GCP).
Create a compose.yml
file with the following content to deploy VeraId Authority and its backing services, using this opportunity to also deploy the server we implemented in the previous step:
services:
kliento-server:
build: .
ports:
- "127.0.0.1:8082:3000"
veraid-authority:
image: ghcr.io/relaycorp/veraid-authority:2.5.1
command: api
ports:
- "127.0.0.1:8080:8080"
environment:
AUTHORITY_VERSION: "1.0.0dev1"
AUTHORITY_SUPERADMIN: admin@veraid.example
MONGODB_URI: mongodb://mongodb/?authSource=admin
MONGODB_USER: root
MONGODB_PASSWORD: password123
MONGODB_DB: endpoint
OAUTH2_JWKS_URL: http://mock-authz-server:8080/default/jwks
OAUTH2_TOKEN_AUDIENCE: default
OAUTH2_TOKEN_ISSUER_REGEX: "^http://[^/]+/default$$"
KMS_ADAPTER: AWS
AWS_ACCESS_KEY_ID: access_key_id
AWS_SECRET_ACCESS_KEY: secret_access_key
AWS_KMS_ENDPOINT: http://mock-aws-kms:8080
AWS_KMS_REGION: eu-west-2
depends_on:
mongodb:
condition: service_healthy
mock-authz-server:
condition: service_started
mock-aws-kms:
condition: service_healthy
mock-authz-server:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- "127.0.0.1:8081:8080"
environment:
JSON_CONFIG: |
{
"tokenCallbacks": [
{
"issuerId": "default",
"tokenExpiry": 3600,
"requestMappings": [
{
"requestParam": "client_id",
"match": "super-admin",
"claims": {"email": "admin@veraid.example"}
},
{
"requestParam": "client_id",
"match": "workload",
"claims": {
"email": "machine@cloud-provider.example",
"aud": "$${audience}"
}
}
]
}
]
}
mock-aws-kms:
image: nsmithuk/local-kms:3.11.4
healthcheck:
test: ["CMD", "sh", "-c", "netstat -an | grep -q ':8080.*LISTEN' || nc -z localhost 8080"]
interval: 5s
retries: 3
mongodb:
image: mongo:8.0.6
command: "--quiet"
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: password123
volumes:
- mongodb-data:/data/db
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
interval: 10s
timeout: 10s
retries: 5
start_period: 40s
volumes:
mongodb-data:
Then start the services:
docker-compose up
At this point, you will have deployed the following services locally:
veraid-authority
: The VeraId Authority server, accessible athttp://localhost:8080
.mock-authz-server
: A mock OAuth2 server, accessible athttp://localhost:8081
.mock-aws-kms
: A server that emulates AWS KMS. You can use any Key Management Service supported by@relaycorp/webcrypto-kms
.mongodb
: A MongoDB server.kliento-server
: The trivial Hono server we implemented previously, accessible athttp://localhost:8082
. Note that although we’re deploying this server in the same Docker Compose project as the other services, it does not depend on any of them.
4. Log in as super-admin
We configured VeraId Authority to use admin@veraid.example
as the super-admin, so we need to obtain a JWT from our mock OAuth2 server to assume that role on the VeraId Authority API by running the following command:
export SUPER_ADMIN_TOKEN=$(curl --request POST \
--url http://localhost:8081/default/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=client_credentials \
--data-urlencode client_id=super-admin \
--data-urlencode client_secret=s3cr3t \
--silent \
| jq -r .access_token)
Double-check that you obtained a JWT by printing the token with echo $SUPER_ADMIN_TOKEN
. You should see a base64-encoded string beginning with ey
.
We’ll act as super-admin when interacting with the VeraId Authority API for the rest of the document, but in production you’ll want to observe the principle of least privilege and use a different role.
5. Configure your domain name
Now it’s time to create an organisation for your domain name. In VeraId, domain names participating in the protocol are called “organisations”.
To simplify the rest of the exercise, let’s store your organisation name in an environment variable:
export ORG_NAME=example.com
Then run the following command to create the organisation:
curl --request POST \
--url http://localhost:8080/orgs \
--header "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
--header 'Content-Type: application/json' \
--data "{\"name\": \"$ORG_NAME\"}" \
--silent \
| jq -r .txtRecordRdata
You should now create a TXT record under the _veraid
subdomain of your domain name (_veraid.$ORG_NAME
), setting its value to the output of the command above. The TXT record value will look like this:
1 XRSDmxhlZhYx+Y8dUy0LWd+Oinp6l6GZsk9gnZpd8S0= 3600
In this example, 1
denotes the key algorithm (RSA), XRSDmxhlZhYx+Y8dUy0LWd+Oinp6l6GZsk9gnZpd8S0=
is the base64-encoded public key, and 3600
is the VeraId TTL override (in seconds). You may adjust the TTL depending on your needs.
6. Configure the VeraId member
VeraId signatures are attributed to members of the organisation, and there are two types of members: bots, which act on behalf of the organisation (e.g. example.com
), and users, which act on their own behalf (e.g. alice@example.com
). The only difference is that users have names and bots don’t.
Run the following command to create a new user named alice
, but feel free to replace alice
with any name you like or remove the name altogether if you’re creating a bot:
export MEMBER_SIGNATURE_SPECS_ENDPOINT=$(curl --request POST \
--url "http://localhost:8080/orgs/$ORG_NAME/members" \
--header "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
--header 'Content-Type: application/json' \
--data '{"name": "alice", "role": "REGULAR"}' \
--silent \
| jq -r .signatureSpecs)
The command above will output the endpoint of the member’s signature specs (e.g. /orgs/$ORG_NAME/members/{memberId}/signature-specs
), which you’ll use in the next step.
7. Configure the signature spec
Clients need to obtain a Kliento token bundle, which is a form of VeraId signature bundle where the payload is a JSON object with the attributes audience
(a string identifying the server where the token bundle will be valid) and claims
(an optional object of key/value pairs). It’s up to the server to define which claims are supported and how they’re used.
To get VeraId Authority to issue signature bundles for the desired payload, you’ll have to create a signature spec for the member created in the previous step. Before creating the signature spec, let’s define the payload and base64-encode it:
export KLIENTO_TOKEN='{"audience": "http://localhost:8082/"}'
# On Windows, use an online encoder like https://www.base64decode.org
export KLIENTO_TOKEN_BASE64=$(echo "$KLIENTO_TOKEN" | base64)
Once the base64-encoded Kliento token is stored in the KLIENTO_TOKEN_BASE64
environment variable, you can create the signature spec by making a POST request to the member’s signature specs endpoint obtained in the previous step:
export SIGNATURE_SPEC=$(cat << EOF
{
"auth": {
"type": "oidc-discovery",
"providerIssuerUrl": "http://mock-authz-server:8080/default",
"jwtSubjectClaim": "email",
"jwtSubjectValue": "machine@cloud-provider.example"
},
"serviceOid": "1.3.6.1.4.1.58708.3.0",
"ttlSeconds": 600,
"plaintext": "${KLIENTO_TOKEN_BASE64}"
}
EOF
)
export EXCHANGE_URL=$(curl --request POST \
--url "http://localhost:8080${MEMBER_SIGNATURE_SPECS_ENDPOINT}" \
--header "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
--header 'Content-Type: application/json' \
--data "$SIGNATURE_SPEC" \
--silent \
| jq -r .exchangeUrl)
Where the signature spec is defined as follows:
auth
requires the client to present a JWT from the mock OAuth2 server and the claimemail
set tomachine@cloud-provider.example
.serviceOid
binds the signature bundles to Kliento (OID1.3.6.1.4.1.58708.3.0
), so they can’t be used in a different context.ttlSeconds
caps the validity of the signature bundles at 10 minutes (600 seconds).plaintext
is the base64-encoded Kliento token ($KLIENTO_TOKEN_BASE64
).
The output of the command above ($EXCHANGE_URL
) is the URL that the client will use to exchange its JWTs for the signature bundles we configured to represent Kliento token bundles.
8. Test it!
We’ll use curl
as our client to demonstrate how to obtain token bundles and use them to make authenticated requests to the server.
First, obtain a JWT from the mock OAuth2 server:
export EXCHANGE_JWT=$(curl --request POST \
--url "http://localhost:8081/default/token" \
--header "Host: mock-authz-server:8080" \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=client_credentials \
--data-urlencode client_id=workload \
--data-urlencode client_secret=s3cr3t \
--data-urlencode "audience=$EXCHANGE_URL" \
--silent \
| jq -r .access_token)
Then exchange the JWT for a token bundle:
export TOKEN_BUNDLE="$(curl \
--url "$EXCHANGE_URL" \
--header 'Accept: application/vnd.veraid.signature-bundle+base64' \
--header "Authorization: Bearer $EXCHANGE_JWT")"
And finally, make a request to the server using the token bundle:
curl --request GET \
--url "http://localhost:8082/" \
--header "Authorization: Kliento $TOKEN_BUNDLE"
The response should report the client’s identifier: user@your-domain.com
or simply your-domain.com
if you created a bot.
Remember that you set the signature spec to expire in 10 minutes, so you can reuse the token bundle as many times as you like within that time frame.
That’s a wrap!
Congratulations! You’ve successfully set up a complete Kliento system on your local machine. You’ve deployed a local VeraId Authority instance, implemented a server that validates token bundles, created domain-based identities, and demonstrated the full authentication flow from JWT exchange to making authenticated requests.
To clean up:
- Stop the Docker Compose process with
Ctrl+C
/Cmd+C
. - Remove the Docker resources:
docker-compose down --volumes --rmi all
- Delete the files we created:
rm compose.yml server.js package.json Dockerfile