Skip to content

Authentication & Sessions

v5 does not use plain bearer tokens. Every request is signed with a key pair held by your client (DPoP, RFC 9449), and the tokens issued at login are bound to that key — a token copied off the wire is useless without the private key.

Three rules govern the whole session:

  • One key pair per session. Generate it once and reuse it for login, every request, and every refresh. The server enforces key continuity.
  • A fresh proof per request. The DPoP header carries a short-lived JWT with the method, the URL, a unique id, a timestamp — and, once authenticated, a hash of the access token (ath).
  • A nonce handshake on the first call. The server requires a server-issued nonce inside each proof. A client that doesn't have one yet gets 400 use_dpop_nonce plus a DPoP-Nonce response header; retry with that value in the proof's nonce claim. Any later response may rotate the nonce — always adopt the latest.

The DPoP client

This is less work than it sounds — the whole client fits in one small class (src/dpop.js in the example project):

js
import { randomUUID } from "node:crypto";

const b64url = (input) => Buffer.from(input).toString("base64url");

export class DpopClient {
    #keyPair; #jwk; #nonce;

    static async create() {
        const client = new DpopClient();
        client.#keyPair = await crypto.subtle.generateKey(
            { name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
        const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", client.#keyPair.publicKey);
        client.#jwk = { kty, crv, x, y };   // public parts only
        return client;
    }

    /** One single-use proof JWT. `accessToken` adds the `ath` claim; omit it for login. */
    async proof(method, url, accessToken) {
        const { origin, pathname } = new URL(url);
        const header = { alg: "ES256", typ: "dpop+jwt", jwk: this.#jwk };
        const payload = {
            jti: randomUUID(),
            htm: method.toUpperCase(),
            htu: `${origin}${pathname}`,     // no query, no fragment
            iat: Math.floor(Date.now() / 1000),
        };
        if (this.#nonce) payload.nonce = this.#nonce;
        if (accessToken) {
            const digest = await crypto.subtle.digest("SHA-256", Buffer.from(accessToken, "utf8"));
            payload.ath = b64url(Buffer.from(digest));
        }
        const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
        const signature = await crypto.subtle.sign(
            { name: "ECDSA", hash: "SHA-256" }, this.#keyPair.privateKey,
            Buffer.from(signingInput, "utf8"));
        return `${signingInput}.${b64url(Buffer.from(signature))}`;
    }

    /** Sends a signed request and absorbs the nonce handshake transparently. */
    async fetch(method, url, { accessToken, headers, ...rest } = {}) {
        const send = async () => {
            const requestHeaders = new Headers(headers);
            requestHeaders.set("DPoP", await this.proof(method, url, accessToken));
            // The scheme is DPoP, not Bearer — a Bearer-prefixed token is rejected.
            if (accessToken) requestHeaders.set("Authorization", `DPoP ${accessToken}`);
            return fetch(url, { ...rest, method, headers: requestHeaders });
        };

        let response = await send();
        this.#adoptNonce(response);
        if (response.status === 400) {
            const body = await response.clone().json().catch(() => null);
            if (body?.error === "use_dpop_nonce" && this.#nonce) {
                response = await send();
                this.#adoptNonce(response);
            }
        }
        return response;
    }

    #adoptNonce(response) {
        const issued = response.headers.get("DPoP-Nonce");
        if (issued) this.#nonce = issued;
    }
}

Logging in

With the client in place, logging in is one call to POST /auth/login:

js
const client = await DpopClient.create();

const response = await client.fetch("POST", "https://<<URL>>/api/v1/auth/login", {
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: "<<EMAIL>>", password: "<<PASSWORD>>" }),
});
const { accessToken, refreshToken } = await response.json();

Things the login can answer besides a token pair:

  • 409 TENANT_CONFLICT_ERROR — your email exists in more than one tenant reachable under this host and the password matches in more than one of them (a password matching a single tenant picks that account on its own). The response lists the candidates; retry with tenantIdentifier (the bare tenant id or the crn#tenant:… form from the list) in the body.
  • 200 with status: "2fa_required" — the tenant enforces two-factor authentication. The response carries a short-lived token to complete the second factor at the 2FA endpoints (TOTP or WebAuthn) before you get a full session.

Making authenticated requests

Every authenticated call needs the access token and a fresh proof with the ath claim — the DpopClient above handles both when you pass accessToken:

js
const devices = await client.fetch("GET", "https://<<URL>>/api/v1/devices?depth=2", {
    accessToken,
});

Token lifecycle

Token lifetimes are short by design:

  • Access tokens live 10 minutes. On a 401, refresh and retry once.
  • Refresh tokens live 8 hours and are rotated on every usePOST /auth/token with { "refreshToken": "…" } returns a new pair; always replace both.
  • To end a session server-side, call POST /auth/logout (add ?all=true to sign out every session of the account).

The example project wraps this pattern — refresh on 401, retry once — in src/session.js, so application code never deals with expiry.

Troubleshooting

SymptomCause / fix
401 on every requestThe Authorization scheme must be DPoP, not Bearer — and the proof must be signed with the key used at login.
400 use_dpop_nonce never resolvesCopy the DPoP-Nonce response header into the next proof's nonce claim. Every response may rotate it — always adopt the latest.
401 "DPoP ath does not match access token"Authenticated calls need the ath claim: base64url(SHA-256(accessToken)).
401 "DPoP proof already used (replay)"Every proof needs a fresh jti — never reuse one.
401 behind a reverse proxyThe proof's htu is checked against origin and path as the server sees them. Forward the real host and scheme.
409 at loginThe email and password match accounts in several tenants on this host — pass tenantIdentifier in the login body.
401 with proofs that look rightClient clock skew: the proof's iat is checked with ±60 s tolerance. The server exposes its Date response header — use it to correct your clock offset.