Authentication
Service Client Authentication using OAuth 2.0 Client Credentials (private_key_jwt, RFC 7523). No shared secrets — authenticate by signing a JWT with your private key.
How it works
- Register a service client in the admin dashboard and receive a
client_id - Get an access token — sign a JWT with your private key, exchange it at
/api/oauth/token, and call any API endpoint using the token as a Bearer token
Register a service client
A tenant admin registers service clients through the admin dashboard. Navigate to Integrations → API Clients and click Create Client.
During registration you provide:
- Name — a human-readable label for this integration
- Description — optional description of the integration
- Permissions — the scopes this client may request
The system automatically generates an ES256 key pair. After creation, the dashboard displays your client_id, key_id, and private key in JWK format (a single-line JSON string). Save the private key immediately — it cannot be retrieved again. Only the public key is stored on the server.
Step 1 — Get an access token
Build a short-lived JWT assertion signed with your private key and exchange it for an access token. The assertion replaces a client_secret — only you can produce this signature. POST the assertion to the token endpoint using application/x-www-form-urlencoded encoding (not JSON).
Required JWT claims:
iss— yourclient_id(e.g.svc_a1b2c3d4e5f6)sub— same asissaud—https://abyrgverslun.is/api/oauth/tokenjti— a unique UUID for each request (replay prevention)exp— short expiry, max 5 minutes fromiat(60s recommended)
Store your private key as an environment variable. The key is in JWK format — a single-line JSON string that is natively .env-friendly:
PRIVATE_KEY='{"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","alg":"ES256","kid":"your-key-id"}'No newline escaping needed. Avoid committing key files to source control.
# 1. Create a client assertion (requires step-cli)
# Convert JWK to PEM first (one-time):
# step crypto key format --jwk --pem < private-key.jwk > private.pem
#
# macOS:
EXP=$(date -v+60S +%s)
# Linux:
# EXP=$(date -d '+60 seconds' +%s)
ASSERTION=$(step crypto jwt sign \
--iss svc_a1b2c3d4e5f6 \
--sub svc_a1b2c3d4e5f6 \
--aud https://abyrgverslun.is/api/oauth/token \
--kid your-key-id \
--key private.pem \
--jti $(uuidgen) \
--exp $EXP)
# 2. Exchange for access token
curl -s -X POST https://abyrgverslun.is/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials\
&client_id=svc_a1b2c3d4e5f6\
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer\
&client_assertion=$ASSERTION\
&scope=devices:read transactions:read" | jq .import { SignJWT, importJWK } from "jose";
import { randomUUID } from "crypto";
const CLIENT_ID = "svc_a1b2c3d4e5f6";
const TOKEN_ENDPOINT = "https://abyrgverslun.is/api/oauth/token";
const jwk = JSON.parse(process.env.PRIVATE_KEY!);
async function getAccessToken(): Promise<string> {
const privateKey = await importJWK(jwk, "ES256");
const assertion = await new SignJWT({
iss: CLIENT_ID,
sub: CLIENT_ID,
aud: TOKEN_ENDPOINT,
jti: randomUUID(),
})
.setProtectedHeader({ alg: "ES256", kid: "your-key-id", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("60s")
.sign(privateKey);
const res = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_assertion_type:
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: assertion,
scope: "devices:read transactions:read",
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(`Token exchange failed: ${error.error}`);
}
const { access_token } = await res.json();
return access_token;
}
// Usage
const token = await getAccessToken();
const devices = await fetch("https://abyrgverslun.is/api/v1/devices", {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());import jwt
import jwt.algorithms
import json
import uuid
import time
import os
import requests
CLIENT_ID = "svc_a1b2c3d4e5f6"
TOKEN_ENDPOINT = "https://abyrgverslun.is/api/oauth/token"
jwk_data = json.loads(os.environ["PRIVATE_KEY"])
PRIVATE_KEY = jwt.algorithms.ECAlgorithm.from_jwk(jwk_data)
def get_access_token() -> str:
now = int(time.time())
assertion = jwt.encode(
{
"iss": CLIENT_ID,
"sub": CLIENT_ID,
"aud": TOKEN_ENDPOINT,
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + 60,
},
PRIVATE_KEY,
algorithm="ES256",
headers={"kid": jwk_data.get("kid", "your-key-id"), "typ": "JWT"},
)
res = requests.post(
TOKEN_ENDPOINT,
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
"scope": "devices:read transactions:read",
},
)
res.raise_for_status()
return res.json()["access_token"]
# Usage
token = get_access_token()
devices = requests.get(
"https://abyrgverslun.is/api/v1/devices",
headers={"Authorization": f"Bearer {token}"},
).json()using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
const string ClientId = "svc_a1b2c3d4e5f6";
const string TokenEndpoint = "https://abyrgverslun.is/api/oauth/token";
var jwk = new JsonWebKey(Environment.GetEnvironmentVariable("PRIVATE_KEY")!);
async Task<string> GetAccessTokenAsync()
{
var assertion = CreateClientAssertion(jwk);
using var http = new HttpClient();
var res = await http.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = ClientId,
["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
["client_assertion"] = assertion,
["scope"] = "devices:read transactions:read",
}));
res.EnsureSuccessStatusCode();
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("access_token").GetString()!;
}
string CreateClientAssertion(JsonWebKey key)
{
var credentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
var now = DateTime.UtcNow;
var descriptor = new SecurityTokenDescriptor
{
Issuer = ClientId,
Subject = new ClaimsIdentity(new[]
{
new Claim("sub", ClientId),
new Claim("jti", Guid.NewGuid().ToString()),
}),
Audience = TokenEndpoint,
IssuedAt = now,
Expires = now.AddSeconds(60),
SigningCredentials = credentials,
TokenType = "JWT",
};
var handler = new JwtSecurityTokenHandler();
return handler.CreateEncodedJwt(descriptor);
}
// Usage
var token = await GetAccessTokenAsync();
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var devices = await http.GetFromJsonAsync<JsonElement>(
"https://abyrgverslun.is/api/v1/devices");Response
/api/oauth/tokenExchange a signed client assertion for an access token.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
| Content-Type | string | Required | application/x-www-form-urlencoded |
Content-TypestringRequiredapplication/x-www-form-urlencoded
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
| grant_type | string | Required | Must be client_credentials |
| client_id | string | Required | Your service client ID (svc_xxx) |
| client_assertion_type | string | Required | urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
| client_assertion | string | Required | The signed JWT assertion |
| scope | string | Optional | Space-separated list of requested scopes — must be a subset of registered scopes |
grant_typestringRequiredMust be client_credentials
client_idstringRequiredYour service client ID (svc_xxx)
client_assertion_typestringRequiredurn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertionstringRequiredThe signed JWT assertion
scopestringOptionalSpace-separated list of requested scopes — must be a subset of registered scopes
Response
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "devices:read transactions:read"
}Step 2 — Use the access token
Include the token in the Authorization header of every API request. Tokens expire in 5 minutes. No refresh tokens are issued — when it expires, create a new assertion and request a fresh token.
/api/verification/passExample authenticated request for age verification. Verification outcomes return HTTP 200 with result pass/fail.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Required | Bearer <access_token> |
| Content-Type | string | Required | application/json |
AuthorizationstringRequiredBearer <access_token>
Content-TypestringRequiredapplication/json
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
| passData | string | Required | Base64-encoded 64-byte pass (legacy 64-char Latin-1 also accepted) |
| requiredAge | number | Required | Minimum age threshold to check (for example, 18) |
passDatastringRequiredBase64-encoded 64-byte pass (legacy 64-char Latin-1 also accepted)
requiredAgenumberRequiredMinimum age threshold to check (for example, 18)
Examples
cURL
ACCESS_TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9..."
curl -s -X POST https://abyrgverslun.is/api/verification/pass \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"passData": "<base64-or-latin1-pass>",
"requiredAge": 18
}' | jq .Response
{
"result": "pass",
"transactionId": "j57b2mNkR4e9..."
}Available permissions
Request only the scopes your integration needs. All resources support both read and write operations.
| Permission | Description |
|---|---|
| devices:read | List and read device details |
| devices:write | Create, update, and delete devices |
| locations:read | Read location information |
| locations:write | Create, update, and delete locations |
| employees:read | Read employee records |
| employees:write | Create, update, and delete employee records |
| transactions:read | Read verification transaction records (read-only) |
| transactions:write | Create verification transactions (age verification) |
Key rotation
Rotate keys without downtime using the Rotate Keys action on the client detail page in the dashboard. A new key pair is generated automatically.
The old key transitions to retiring status for a 24-hour grace window before moving to retired. Update your signing key during the grace window to avoid interruption.
Instant revocation
An admin can instantly invalidate all active tokens for a client via the Revoke All Tokens action in the dashboard. This sets tokensInvalidBefore to the current timestamp. Any token issued before that timestamp is immediately rejected on next use — no token blacklist required.
Security best practices
- No shared secrets — the server stores only public keys; a database breach does not expose client credentials
- Short-lived assertions — assertions expire in 60s; combined with JTI replay prevention, intercepted assertions cannot be reused
- Scope restriction — only request the permissions your integration actually needs
- No refresh tokens — eliminates refresh token theft as an attack vector
- Rotate keys without downtime — use the key rotation action and the 24-hour retiring grace window
- Instant revocation — use
tokensInvalidBeforeto immediately revoke all tokens if a client is compromised