User and device-code authentication

Use this path when a person signs in: a native app, a CLI, or anything acting on a human's behalf. Build your own client using one of two documented flows below: the OAuth 2.0 device-code flow, or the PKCE loopback flow when a browser is available on the same machine.

The client ID is per-environment

The public native client is a first-party, public client (it uses PKCE and holds no secret). Its client_id is a per-environment value.

The client_id in the examples below is this environment's value.

The device flow

The device flow has two request-bearing steps: ask for a device code, then poll the token endpoint until the person approves.

1Request a device code

POST the device authorization endpoint with your client ID, the scopes and the audience.

curl
curl -sS -X POST https://auth.cybersentriq.com/oauth/device/code \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=YM8rjZukoH5rG2hnhMK82lSMAZYP57WR" \
  --data-urlencode "scope=openid profile email offline_access api:full:read api:full:write" \
  --data-urlencode "audience=https://api.cybersentriq.com"
TypeScript / Node
const params = new URLSearchParams({
  client_id: "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
  scope: "openid profile email offline_access api:full:read api:full:write",
  audience: "https://api.cybersentriq.com",
});

const res = await fetch("https://auth.cybersentriq.com/oauth/device/code", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: params,
});
const device = await res.json();
console.log("Go to", device.verification_uri_complete);
Python
import requests

res = requests.post(
    "https://auth.cybersentriq.com/oauth/device/code",
    data={
        "client_id": "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
        "scope": "openid profile email offline_access api:full:read api:full:write",
        "audience": "https://api.cybersentriq.com",
    },
)
device = res.json()
print("Go to", device["verification_uri_complete"])
C#
using System.Net.Http;
using System.Text.Json;

var http = new HttpClient();
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
    ["client_id"] = "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
    ["scope"] = "openid profile email offline_access api:full:read api:full:write",
    ["audience"] = "https://api.cybersentriq.com",
});

var res = await http.PostAsync("https://auth.cybersentriq.com/oauth/device/code", form);
using var device = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Console.WriteLine("Go to " + device.RootElement.GetProperty("verification_uri_complete").GetString());
Go
form := url.Values{
    "client_id": {"YM8rjZukoH5rG2hnhMK82lSMAZYP57WR"},
    "scope":     {"openid profile email offline_access api:full:read api:full:write"},
    "audience":  {"https://api.cybersentriq.com"},
}

res, err := http.PostForm("https://auth.cybersentriq.com/oauth/device/code", form)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()

var device struct {
    DeviceCode              string `json:"device_code"`
    UserCode                string `json:"user_code"`
    VerificationURIComplete string `json:"verification_uri_complete"`
    Interval                int    `json:"interval"`
    ExpiresIn               int    `json:"expires_in"`
}
json.NewDecoder(res.Body).Decode(&device)
fmt.Println("Go to", device.VerificationURIComplete)

Request the coarse access scopes for now โ€” see Permissions.

The response looks like this (your values will differ; always use the URIs and codes the response returns):

JSON
{
  "device_code": "GmRh...long-opaque-value",
  "user_code": "ABCD-EFGH",
  "verification_uri": "https://auth.cybersentriq.com/activate",
  "verification_uri_complete": "https://auth.cybersentriq.com/activate?user_code=ABCD-EFGH",
  "expires_in": 900,
  "interval": 5
}

2Ask the person to approve

Show the user_code and the verification_uri, or open verification_uri_complete (which pre-fills the code). The person signs in and approves. Your client keeps polling in the meantime.

3Poll the token endpoint

POST the token endpoint with the device_code grant, every interval seconds. While approval is outstanding you get HTTP 400 with error: authorization_pending; keep polling. On slow_down, add 5 seconds to your interval. On expired_token, start again from step 1. On success you get HTTP 200 with the tokens.

curl
# Repeat every `interval` seconds until you get 200, or a non-pending error.
curl -sS -X POST https://auth.cybersentriq.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  --data-urlencode "device_code=<DEVICE_CODE>" \
  --data-urlencode "client_id=YM8rjZukoH5rG2hnhMK82lSMAZYP57WR"
TypeScript / Node
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function pollForToken(deviceCode: string, interval: number) {
  const body = new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:device_code",
    device_code: deviceCode,
    client_id: "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
  });

  for (;;) {
    const res = await fetch("https://auth.cybersentriq.com/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body,
    });
    const data = await res.json();
    if (res.ok) return data; // { access_token, refresh_token, ... }
    if (data.error === "authorization_pending") { await sleep(interval * 1000); continue; }
    if (data.error === "slow_down") { interval += 5; await sleep(interval * 1000); continue; }
    throw new Error(data.error); // expired_token, access_denied, ...
  }
}
Python
import time
import requests

def poll_for_token(device_code, interval):
    data = {
        "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
        "device_code": device_code,
        "client_id": "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
    }
    while True:
        res = requests.post("https://auth.cybersentriq.com/oauth/token", data=data)
        body = res.json()
        if res.ok:
            return body  # {"access_token": ..., "refresh_token": ...}
        error = body.get("error")
        if error == "authorization_pending":
            time.sleep(interval); continue
        if error == "slow_down":
            interval += 5; time.sleep(interval); continue
        raise RuntimeError(error)  # expired_token, access_denied, ...
C#
using System.Text.Json;

async Task<JsonElement> PollForToken(HttpClient http, string deviceCode, int interval)
{
    while (true)
    {
        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
            ["device_code"] = deviceCode,
            ["client_id"] = "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
        });

        var res = await http.PostAsync("https://auth.cybersentriq.com/oauth/token", form);
        using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
        if (res.IsSuccessStatusCode) return doc.RootElement.Clone();

        var error = doc.RootElement.GetProperty("error").GetString();
        if (error == "authorization_pending") { await Task.Delay(interval * 1000); continue; }
        if (error == "slow_down") { interval += 5; await Task.Delay(interval * 1000); continue; }
        throw new Exception(error); // expired_token, access_denied, ...
    }
}
Go
func pollForToken(client *http.Client, deviceCode string, interval int) (map[string]any, error) {
    for {
        form := url.Values{
            "grant_type":  {"urn:ietf:params:oauth:grant-type:device_code"},
            "device_code": {deviceCode},
            "client_id":   {"YM8rjZukoH5rG2hnhMK82lSMAZYP57WR"},
        }

        res, err := client.PostForm("https://auth.cybersentriq.com/oauth/token", form)
        if err != nil {
            return nil, err
        }
        var body map[string]any
        json.NewDecoder(res.Body).Decode(&body)
        res.Body.Close()

        if res.StatusCode == http.StatusOK {
            return body, nil // body["access_token"], body["refresh_token"], ...
        }
        switch body["error"] {
        case "authorization_pending":
            time.Sleep(time.Duration(interval) * time.Second)
        case "slow_down":
            interval += 5
            time.Sleep(time.Duration(interval) * time.Second)
        default:
            return nil, fmt.Errorf("device flow failed: %v", body["error"])
        }
    }
}

A successful response carries the access token you send to the API, and (because offline_access is enabled) a refresh token:

JSON
{
  "access_token": "eyJhbGciOiJSUzI1Ni(truncated)",
  "refresh_token": "v1.MjAy(truncated)",
  "id_token": "eyJhbGciOiJSUzI1Ni(truncated)",
  "scope": "openid profile email offline_access",
  "expires_in": 86400,
  "token_type": "Bearer"
}

Store the refresh_token to renew access without asking the person to sign in again. See Refreshing tokens.

Scopes and permissions

The request asks for the coarse access scopes (api:full:read / api:full:write) alongside the standard OIDC scopes; it does not enumerate individual API permissions. Your effective permissions are separate: they are resolved per org and role and delivered in the https://cybersentriq.com/permissions claim. You do not request permissions here; you receive them. See Permissions and Authentication overview.

Fallback: PKCE loopback

When a browser is available on the same machine (a desktop app, a local dev tool), the Authorization Code flow with PKCE is a good alternative. To build it yourself:

  1. Create a random code_verifier and its SHA-256 code_challenge.
  2. Open the browser at https://auth.cybersentriq.com/authorize with response_type=code, client_id=YM8rjZukoH5rG2hnhMK82lSMAZYP57WR, a redirect_uri that exactly matches one of the registered loopback URIs listed below, scope=openid profile email offline_access, audience=https://api.cybersentriq.com, code_challenge, code_challenge_method=S256 and a random state.
  3. Capture the code on your loopback listener, then exchange it for tokens.

Step 1 in detail: the PKCE code_verifier and code_challenge

PKCE (Proof Key for Code Exchange, RFC 7636) is what lets a public client (one with no secret) prove that the app redeeming the authorization code is the same app that started the flow. It trips people up because two closely-named values do two different jobs, and the encoding has to be exactly right. Take it slowly and it is straightforward.

The code_verifier is a high-entropy random string you generate and keep private. RFC 7636 requires 43โ€“128 characters drawn only from the unreserved set [A-Za-z0-9-._~]. In practice you do not pick characters by hand: you take 32 random bytes and base64url-encode them without padding, which yields a 43-character string that is unreserved by construction. Generate a fresh one for every sign-in.

The code_challenge is derived from the verifier, not chosen:

code_challenge = BASE64URL-NO-PAD( SHA256( ASCII(code_verifier) ) )
code_challenge_method = S256

You SHA-256 the ASCII bytes of the verifier string, then base64url-encode the 32-byte digest without padding (again 43 characters). You send the challenge to /authorize; you keep the verifier and send it to /oauth/token at the exchange. The authorization server re-runs the same SHA-256 on the verifier you present and checks it matches the challenge it saw earlier, so only the client that holds the original verifier can redeem the code.

Compute the values

Generate a verifier, then derive its challenge:

curl
# code_verifier: 32 random bytes, base64url, no padding -> 43 chars.
code_verifier=$(openssl rand 32 | basenc --base64url -w0 | tr -d '=')

# code_challenge: SHA-256 the verifier's bytes, base64url, no padding.
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -binary -sha256 \
  | basenc --base64url -w0 | tr -d '=')

echo "code_verifier=$code_verifier"
echo "code_challenge=$code_challenge  (method=S256)"
TypeScript / Node
import { randomBytes, createHash } from "node:crypto";

// Node's "base64url" is already the RFC alphabet (-_) with no padding.
const b64url = (b: Buffer) => b.toString("base64url");

const codeVerifier = b64url(randomBytes(32)); // 43 chars, unreserved set
const codeChallenge = b64url(createHash("sha256").update(codeVerifier).digest());

console.log("code_verifier=", codeVerifier);
console.log("code_challenge=", codeChallenge, "(method=S256)");
Python
import base64, hashlib, os

def b64url(raw: bytes) -> str:
    # urlsafe_b64encode gives the -_ alphabet; strip the = padding ourselves.
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")

code_verifier = b64url(os.urandom(32))  # 43 chars, unreserved set
code_challenge = b64url(hashlib.sha256(code_verifier.encode("ascii")).digest())

print("code_verifier=", code_verifier)
print("code_challenge=", code_challenge, "(method=S256)")
C#
using System.Security.Cryptography;
using System.Text;

// Convert.ToBase64String is STANDARD base64 (+/ with =); map it to base64url.
static string B64Url(byte[] raw) =>
    Convert.ToBase64String(raw).TrimEnd('=').Replace('+', '-').Replace('/', '_');

var codeVerifier = B64Url(RandomNumberGenerator.GetBytes(32)); // 43 chars
var codeChallenge = B64Url(SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)));

Console.WriteLine($"code_verifier={codeVerifier}");
Console.WriteLine($"code_challenge={codeChallenge} (method=S256)");
Go
// imports: crypto/rand, crypto/sha256, encoding/base64, fmt, log
verifierBytes := make([]byte, 32)
if _, err := rand.Read(verifierBytes); err != nil {
    log.Fatal(err)
}
// RawURLEncoding is base64url (-_) WITHOUT padding โ€” exactly what PKCE wants.
codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes) // 43 chars

sum := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:])

fmt.Println("code_verifier=", codeVerifier)
fmt.Println("code_challenge=", codeChallenge, "(method=S256)")

Verify your implementation

Before wiring the full browser flow, self-check your compute step against the fixed test vector published in RFC 7636 Appendix B: the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk must derive the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM. If your code prints PASS, your SHA-256 and base64url encoding are correct; if it prints FAIL, the mismatch is almost always one of the pitfalls listed below.

curl
verifier="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
expected="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

got=$(printf '%s' "$verifier" \
  | openssl dgst -binary -sha256 \
  | basenc --base64url -w0 | tr -d '=')

[ "$got" = "$expected" ] && echo "PASS" || echo "FAIL: got $got"
TypeScript / Node
import { createHash } from "node:crypto";

const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";

const got = createHash("sha256").update(verifier).digest().toString("base64url");
console.log(got === expected ? "PASS" : `FAIL: got ${got}`);
Python
import base64, hashlib

verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

got = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode("ascii")).digest()
).rstrip(b"=").decode("ascii")

print("PASS" if got == expected else f"FAIL: got {got}")
C#
using System.Security.Cryptography;
using System.Text;

var verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
var expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";

var got = Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)))
    .TrimEnd('=').Replace('+', '-').Replace('/', '_');

Console.WriteLine(got == expected ? "PASS" : $"FAIL: got {got}");
Go
// imports: crypto/sha256, encoding/base64, fmt
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
expected := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

sum := sha256.Sum256([]byte(verifier))
got := base64.RawURLEncoding.EncodeToString(sum[:])

if got == expected {
    fmt.Println("PASS")
} else {
    fmt.Println("FAIL: got", got)
}

Pitfalls that produce a mismatch or an invalid_grant at the exchange:

  • base64url, not standard base64. The alphabet must use - and _, never + and /. Languages whose base64 defaults to the standard alphabet (C#'s Convert.ToBase64String, Java, some others) must map the two characters.
  • Strip the = padding. PKCE uses base64url without padding. Trailing = characters make both the verifier and the challenge invalid.
  • Hash the verifier string, not the random bytes. The challenge is SHA256 of the ASCII bytes of the encoded verifier string, not of the 32 raw bytes you started with. Encode first, then hash the encoded text.
  • Challenge to /authorize, verifier to /oauth/token. Sending the verifier to /authorize, or the challenge to the token endpoint, is the most common wiring mistake. Only the challenge is public; the verifier is the secret you reveal at the very end.
  • redirect_uri must match exactly. The value you pass to both /authorize and /oauth/token must be byte-for-byte one of the registered loopback URIs below: localhost, the exact port, and no path (not even a trailing /).

The client registers a fixed set of loopback redirect URIs. Start your listener on one of these exact ports and pass the URL verbatim as redirect_uri; note it is localhost with no path:

http://localhost:53393
http://localhost:57592
http://localhost:57777
http://localhost:60415
http://localhost:61279

The exchange is the request-bearing step:

curl
curl -sS -X POST https://auth.cybersentriq.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=YM8rjZukoH5rG2hnhMK82lSMAZYP57WR" \
  --data-urlencode "code=<AUTHORIZATION_CODE>" \
  --data-urlencode "code_verifier=<CODE_VERIFIER>" \
  --data-urlencode "redirect_uri=http://localhost:57777"
TypeScript / Node
const body = new URLSearchParams({
  grant_type: "authorization_code",
  client_id: "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
  code: "<AUTHORIZATION_CODE>",
  code_verifier: "<CODE_VERIFIER>",
  redirect_uri: "http://localhost:57777",
});

const res = await fetch("https://auth.cybersentriq.com/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body,
});
const tokens = await res.json();
Python
import requests

res = requests.post(
    "https://auth.cybersentriq.com/oauth/token",
    data={
        "grant_type": "authorization_code",
        "client_id": "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
        "code": "<AUTHORIZATION_CODE>",
        "code_verifier": "<CODE_VERIFIER>",
        "redirect_uri": "http://localhost:57777",
    },
)
tokens = res.json()
C#
using System.Net.Http;

var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
    ["grant_type"] = "authorization_code",
    ["client_id"] = "YM8rjZukoH5rG2hnhMK82lSMAZYP57WR",
    ["code"] = "<AUTHORIZATION_CODE>",
    ["code_verifier"] = "<CODE_VERIFIER>",
    ["redirect_uri"] = "http://localhost:57777",
});

var res = await http.PostAsync("https://auth.cybersentriq.com/oauth/token", form);
var tokens = await res.Content.ReadAsStringAsync();
Go
form := url.Values{
    "grant_type":    {"authorization_code"},
    "client_id":     {"YM8rjZukoH5rG2hnhMK82lSMAZYP57WR"},
    "code":          {"<AUTHORIZATION_CODE>"},
    "code_verifier": {"<CODE_VERIFIER>"},
    "redirect_uri":  {"http://localhost:57777"},
}

res, err := http.PostForm("https://auth.cybersentriq.com/oauth/token", form)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
// Decode res.Body into your token struct.

The response shape matches the device flow: an access_token, a refresh_token (with offline_access), and an id_token.

Step-up: some actions need recent MFA

Some sensitive actions require that you completed multi-factor authentication recently, not just at some point in this session. When your MFA is too old, the API refuses the call with HTTP 403 and error: step_up_required; you clear it by signing in again with MFA and retrying. The driving claims, how to tell it apart from a 401, and how to re-authenticate are in the dedicated Step-up MFA guide.

Next