Authentication
There are two kinds of caller:
| Caller | How it authenticates | Used for |
|---|---|---|
| People | Email login to the web console (a one-time code or magic link; no passwords) → session cookies | Managing everything in the browser; creating API tokens |
| Programs (CI, factory tools, scripts) | API token in Authorization: Bearer <token> |
Uploads, downloads, automation |
| Devices checking for updates | Nothing (the update check is public) | GET /firmware/{domain}/… |
The token examples on this page use the public sandbox tokens (see Getting started), so you can run them as they are.
Console login (email proof of ownership)
Login is by invitation only. An owner of a customer (or a service admin) invites an email address with a role. The address can be at any domain, and one person can belong to several customers. Nobody else can log in.
- Enter your email on the login page. If the address is invited, you get an email with a 6-digit code and a magic link. Both are valid for 15 minutes and can be used once.
- Enter the code, or open the link and press Continue.
- You now have a session:
- an access cookie that lasts 15 minutes and is renewed automatically;
- a refresh cookie that keeps you logged in while you're active. After 12 hours of inactivity, or 7 days in total, you must prove your email ownership again.
For security the login endpoint always answers 202, whether or not the address is
invited. There are at most 5 codes per address per hour and 5 wrong guesses per code.
Log in from a script
The same flow is available as JSON, for single-page apps or scripts that want a session — for example to create tokens or manage members through the API:
| Method & path | Body | Result |
|---|---|---|
POST /auth/login |
{"email": "…"} |
202 always (a code is emailed if invited) |
POST /auth/verify |
{"email": "…", "code": "123456"} |
200 + session cookies + csrf_token |
GET /auth/me |
– | who you are, memberships, scopes, csrf_token |
POST /auth/refresh |
– | rotates the refresh cookie, issues a new access cookie |
POST /auth/switch |
{"domain": "…"} |
switch the active customer (you must be a member) |
POST /auth/logout |
– | 204, session revoked, cookies cleared |
Request a code:
curl -s -X POST https://fw.unitcircle.ca/auth/login -H 'Content-Type: application/json' \
-d '{"email":"alice@example.com"}'
HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8
{
"detail": "If this address is invited, a login code has been emailed to it.",
"sent": true
}
The email reads:
Subject: Your uclogserver login code: 525913
Your uclogserver login code is: 525913
Or open this link to log in:
https://fw.unitcircle.ca/auth/magic?c=01a0d9c2-…&t=rdwuRcIg…
The code and link expire in 15 minutes and can be used once.
Verify the code. -c cookies.txt stores the session cookies:
curl -s -c cookies.txt -X POST https://fw.unitcircle.ca/auth/verify -H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","code":"525913"}'
HTTP/1.1 200 OK
Set-Cookie: __Host-ucl_rt=…; Path=/; HttpOnly; Secure; SameSite=Strict
Set-Cookie: __Host-ucl_csrf=…; Path=/; HttpOnly; Secure; SameSite=Strict
Set-Cookie: __Host-ucl_at=…; Path=/; HttpOnly; Secure; SameSite=Strict
{
"admin": "",
"csrf_token": "7s6Ck0pDB_J1gMo1lTQhGeZyiks_U1F8",
"customer": "example.com",
"email": "alice@example.com",
"memberships": [ { "domain": "example.com", "role": "owner" } ],
"role": "owner",
"scopes": [ "devices:read", "devices:write", "fw:read", "fw:write",
"logdata:read", "logdata:write", "members:manage", "stats:read",
"tags:read", "tags:write" ]
}
Failure cases:
POST /auth/verify {"email":"alice@example.com","code":"000000"}
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
{"detail":"invalid or expired code","status":401,"title":"Unauthorized", …}
POST /auth/login {"email":"bad"}
HTTP/1.1 400 Bad Request
{"detail":"a valid email address is required", …}
CSRF protection
Session cookies are SameSite=Strict. In addition, every state-changing request
authenticated by cookies (POST, PUT, PATCH, DELETE, including /auth/refresh,
/auth/switch and /auth/logout) must send the CSRF token. Send it in an
X-CSRF-Token header, or as a csrf form field or query parameter. Get the token from
/auth/verify or /auth/me:
curl -s -b cookies.txt https://fw.unitcircle.ca/auth/me | jq -r .csrf_token
Without it, a cookie-authenticated write is refused:
curl -s -b cookies.txt -X POST https://fw.unitcircle.ca/tokens \
-H 'Content-Type: application/json' -d '{"name":"ci","bundle":"uploader"}'
HTTP/1.1 403 Forbidden
{"detail":"missing or invalid CSRF token", …}
Adding -H "X-CSRF-Token: <csrf_token>" (the value from /auth/me) makes the same
request succeed. In Python, a requests.Session()
that posts to /auth/verify keeps the cookies for you; read csrf_token from the response. Requests authenticated with a
Bearer token don't need a CSRF token.
Session expiry
POST /auth/refresh (no or expired refresh cookie)
HTTP/1.1 401 Unauthorized
{"detail":"session expired; please log in again", …}
Refresh tokens rotate on every use. If an old refresh token is presented again (a sign it was stolen), the whole session is revoked and the admins are alerted.
API tokens
API tokens are JWTs signed with Ed25519 (alg: EdDSA). Create them in the console
(Tokens page) or with the tokens API while logged in. A token:
- belongs to one customer (
custclaim) and carries a set of scopes (scp); - expires (default 90 days, maximum 365 days);
- can be revoked at any time; revocation takes effect within 30 seconds;
- is shown once, when created. The service stores only its id (
jti).
Send it as a Bearer token. The token determines the customer, so the URL doesn't name it:
curl -s https://fw.unitcircle.ca/devices -H "Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAtMDEtYjItaiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Z3LnVuaXRjaXJjbGUuY2EiLCJzdWIiOiJzdmM6c2FuZGJveCByZWFkLW9ubHkgKHB1YmxpYykiLCJhdWQiOlsidWNsb2dzZXJ2ZXIiXSwiZXhwIjoxODIyNDE0NTE3LCJuYmYiOjE3OTA4Nzg1MTIsImlhdCI6MTc5MDg3ODUxNywianRpIjoiMDFhMGY4YWQtODkwZi03ODA5LWI3YTgtMDE0MTZlOWM0YTI3IiwidHlwIjoiYXBpIiwiY3VzdCI6InNhbmRib3guZXhhbXBsZSIsImNpZCI6IjAxYTBmOGExLTEzYWMtN2QzNC05NDEyLTMwYjAwOGFkYzk4NyIsInNjcCI6ImZ3OnJlYWQgbG9nZGF0YTpyZWFkIGRldmljZXM6cmVhZCB0YWdzOnJlYWQgc3RhdHM6cmVhZCJ9.IaQSEW8c2zmJWEioQ8PBaezftMriGUzGQhIof_T7HGG4Usjt9dRmhj-xiw_tMuEYcsR7EzLzXyELG0DrdJGADQ"
Service administrators, whose tokens work on every customer, choose one with
?customer=: GET /devices?customer=example.com. For anyone else ?customer= must be
their own customer or the answer is 404 customer not found.
A decoded token payload looks like this:
{
"iss": "https://fw.unitcircle.ca",
"sub": "user:01a0d9c2-a9f4-7330-b295-67ff298771aa",
"aud": ["uclogserver"],
"exp": 1798135819, "nbf": 1790359814, "iat": 1790359819,
"jti": "01a0d9c2-d5cd-7dea-82e0-44330ea13bcc",
"typ": "api",
"cust": "example.com",
"cid": "01a0d9c2-7bde-7afc-9eba-2090618451f3",
"scp": "devices:read fw:read fw:write logdata:read logdata:write stats:read tags:read"
}
The public signing keys are published as a JWK set at
/.well-known/jwks.json, so you can validate tokens offline:
{ "keys": [ { "alg": "EdDSA", "crv": "Ed25519", "kid": "2026-09-25-AbCd", "kty": "OKP",
"use": "sig", "x": "njhynTM5__vh7w8s7KYd-_P_1iqFbr46TrvrdgUlfiM" } ] }
Scopes
| Scope | Allows |
|---|---|
fw:read |
list/get firmware, download binaries and notes, parse (dry run), read firmware tags |
fw:write |
upload firmware, change release settings (tags, tag_filter_disabled, status) |
logdata:read |
list, get metadata, download log files |
logdata:write |
upload log files |
devices:read |
list/get devices and their tags |
devices:write |
create/update/import devices, withdraw and reactivate them |
tags:read |
list tags with counts |
tags:write |
set/add/remove tags on firmware and devices, rename/delete tags |
stats:read |
usage statistics and the access log for your customer |
members:manage |
invite, change and remove people (owners only) |
admin |
service administrators only: every scope on every customer plus the admin API |
There are no delete scopes: firmware builds, log files and devices are never deleted (builds and devices are withdrawn instead).
Bundles offered in the console:
| Bundle | Scopes |
|---|---|
read-only |
fw:read logdata:read devices:read tags:read stats:read |
uploader |
read-only + fw:write logdata:write |
full |
all :read and :write scopes (not members:manage, not admin) |
Roles
| Role | Scopes in that customer |
|---|---|
viewer |
all :read scopes |
editor |
viewer + all :write scopes |
owner |
editor + members:manage |
A token can never have more scopes than the person who created it. Service administrators are the members of the operator's admin customer. A viewer there is a read-only admin; an editor or owner is a full admin.
Token failures
| Situation | Response |
|---|---|
No Authorization header on a protected URL |
401 authentication required (Authorization: Bearer <token>) + WWW-Authenticate: Bearer realm="uclogserver" |
Not a Bearer header (e.g. Basic …) |
401 Authorization header must be 'Bearer <token>' |
| Malformed or badly signed token | 401 invalid token: … |
| Expired, revoked or unknown token | 401 token revoked, expired or unknown |
| A token in a format this service doesn't issue | 401 token format no longer supported; create a new token in the console |
| Valid token, missing scope | 403 missing scope fw:write |
?customer= naming a customer the token doesn't belong to |
404 customer not found |
Examples:
curl -si https://fw.unitcircle.ca/devices
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Www-Authenticate: Bearer realm="uclogserver"
{"detail":"authentication required (Authorization: Bearer \u003ctoken\u003e)","instance":"/devices","request_id":"9a277c35-…","status":401,"title":"Unauthorized","type":"about:blank"}
# Asking for someone else's customer
curl -si "https://fw.unitcircle.ca/devices?customer=unitcircle.ca" -H "Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAtMDEtYjItaiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Z3LnVuaXRjaXJjbGUuY2EiLCJzdWIiOiJzdmM6c2FuZGJveCByZWFkLW9ubHkgKHB1YmxpYykiLCJhdWQiOlsidWNsb2dzZXJ2ZXIiXSwiZXhwIjoxODIyNDE0NTE3LCJuYmYiOjE3OTA4Nzg1MTIsImlhdCI6MTc5MDg3ODUxNywianRpIjoiMDFhMGY4YWQtODkwZi03ODA5LWI3YTgtMDE0MTZlOWM0YTI3IiwidHlwIjoiYXBpIiwiY3VzdCI6InNhbmRib3guZXhhbXBsZSIsImNpZCI6IjAxYTBmOGExLTEzYWMtN2QzNC05NDEyLTMwYjAwOGFkYzk4NyIsInNjcCI6ImZ3OnJlYWQgbG9nZGF0YTpyZWFkIGRldmljZXM6cmVhZCB0YWdzOnJlYWQgc3RhdHM6cmVhZCJ9.IaQSEW8c2zmJWEioQ8PBaezftMriGUzGQhIof_T7HGG4Usjt9dRmhj-xiw_tMuEYcsR7EzLzXyELG0DrdJGADQ"
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"detail":"customer not found","instance":"/devices","request_id":"ec1160ae-…","status":404,"title":"Not Found","type":"about:blank"}
# A token you revoked in the console
curl -si https://fw.unitcircle.ca/devices -H "Authorization: Bearer <revoked token>"
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer realm="uclogserver", error="invalid_token"
{"detail":"token revoked, expired or unknown", …}
# The read-only token trying to upload
curl -si -X PUT https://fw.unitcircle.ca/firmware/example-hw/2.0.0/demo -H "Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAtMDEtYjItaiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Z3LnVuaXRjaXJjbGUuY2EiLCJzdWIiOiJzdmM6c2FuZGJveCByZWFkLW9ubHkgKHB1YmxpYykiLCJhdWQiOlsidWNsb2dzZXJ2ZXIiXSwiZXhwIjoxODIyNDE0NTE3LCJuYmYiOjE3OTA4Nzg1MTIsImlhdCI6MTc5MDg3ODUxNywianRpIjoiMDFhMGY4YWQtODkwZi03ODA5LWI3YTgtMDE0MTZlOWM0YTI3IiwidHlwIjoiYXBpIiwiY3VzdCI6InNhbmRib3guZXhhbXBsZSIsImNpZCI6IjAxYTBmOGExLTEzYWMtN2QzNC05NDEyLTMwYjAwOGFkYzk4NyIsInNjcCI6ImZ3OnJlYWQgbG9nZGF0YTpyZWFkIGRldmljZXM6cmVhZCB0YWdzOnJlYWQgc3RhdHM6cmVhZCJ9.IaQSEW8c2zmJWEioQ8PBaezftMriGUzGQhIof_T7HGG4Usjt9dRmhj-xiw_tMuEYcsR7EzLzXyELG0DrdJGADQ" \
--form-string 'firmware=@(#)9.9.9, 2026-01-01T00:00:00Z, AFI' --form-string 'notes=# 9.9.9'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
403 Forbidden: missing scope fw:write
Public (no authentication)
/status, /healthz, /readyz, /health/history, /.well-known/jwks.json,
/openapi.json, the update check (GET /firmware/{domain}/{hw}/{hwver}/{fw}/{fwver}[/{hwid}]),
download links (/dl/…), the public health page, this documentation and the
login pages.