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 -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"
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);
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"])
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());
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):
{
"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.
# 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"
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, ...
}
}
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, ...
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, ...
}
}
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:
{
"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:
- Create a random
code_verifierand its SHA-256code_challenge. - Open the browser at
https://auth.cybersentriq.com/authorizewithresponse_type=code,client_id=YM8rjZukoH5rG2hnhMK82lSMAZYP57WR, aredirect_urithat 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=S256and a randomstate. - Capture the
codeon 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:
# 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)"
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)");
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)")
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)");
// 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.
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"
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}`);
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}")
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}");
// 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#'sConvert.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
SHA256of 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_urimust match exactly. The value you pass to both/authorizeand/oauth/tokenmust 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 -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"
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();
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()
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();
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
- Renew without a fresh sign-in: Refreshing tokens.
- Call the API with your token: Endpoints you call and the End-to-end example.