dataroom.dev

Engineering

OAuth 2.1 device flow with PKCE for virtual data room APIs: a complete walkthrough

How OAuth 2.1 device authorization grant works in practice, how a modern dataroom API implements it, and how to add device-flow login to a CLI or distributed tool you are building: worked example uses the Papermark API.

Read full docsRead the full authentication & OAuth documentation
May 5, 2026·9 min read·By dataroom.dev

If you've used gh auth login, aws sso login, papermark login, the Stripe CLI's auth, or any of the dozen other modern developer tools that authenticate to a SaaS service from the terminal, you've used the OAuth 2.1 device authorization grant: informally "device flow." It's the right authentication primitive for tools that run on a machine without a browser, can't reliably bind to a localhost port, or get distributed to end users who shouldn't see your application's client secrets.

This article walks through what device flow is, why it exists, how Papermark's implementation works, and how to wire it into a tool you're building. It assumes intermediate familiarity with HTTP and bearer-token auth, but explains the OAuth-specific concepts as it goes. If you've ever wondered why gh auth login shows you a code and a URL instead of just opening a browser, this is the answer.

When to use device flow (and when not to)

Three signals you should be using device flow:

  1. Your tool is a CLI, daemon, IoT device, or other "browserless" client that can't open a browser at the OS level reliably across all the platforms it runs on. CLIs running on remote SSH sessions, headless servers, locked-down corporate workstations, and Linux desktops with no default browser all benefit.
  2. You distribute the tool to end users who own the credentials, not just to your own machines. Public CLIs (gh, papermark, Stripe, AWS) all fall into this bucket. The tool author cannot embed long-lived credentials because they'd be shared across all installs.
  3. You want auto-refresh of access tokens so the user only authenticates once per ~90 days rather than every session.

Three signals you should not be using device flow:

  1. You only need to authenticate one machine you own. A long-lived dashboard token (pm_live_… for Papermark, from app.papermark.com/settings/tokens) is simpler. No token-rotation logic, no PKCE handling, no polling.
  2. Your tool runs in a CI environment where there's no human to enter a code. Use a static token from a secret manager (GitHub Actions secret, AWS Secrets Manager, Vault) instead.
  3. You're authenticating server-to-server with no user identity involved. Use client credentials grant, not device flow.

The mental model: device flow is interactive auth for non-interactive clients. If either half of that doesn't apply, use something else.

The protocol in 6 steps

┌─ Tool ─────────────────┐                     ┌─ Auth server ───────────┐
│ 1. POST /device/code   │ ──── client_id ───▶ │                         │
│                        │      scope          │                         │
│                        │      code_challenge │                         │
│ 2. Receive             │ ◀─── device_code,   │                         │
│    verification URL +  │      user_code,     │                         │
│    user_code           │      interval,      │                         │
│                        │      expires_in     │                         │
│                        │                     │                         │
│ 3. Display URL + code  │                     │                         │
│    to the user         │                     │                         │
│                        │                     │  (user opens URL,       │
│                        │                     │   logs in,              │
│                        │                     │   enters code,          │
│                        │                     │   approves scopes)      │
│                        │                     │                         │
│ 4. Poll /token         │ ──── device_code ─▶ │                         │
│                        │ ◀─── pending ────── │  (user not yet acted)   │
│                        │ ──── device_code ─▶ │                         │
│                        │ ◀─── slow_down ──── │  (polling too fast)     │
│                        │ ──── device_code ─▶ │                         │
│                        │ ◀─── access_token ─ │  (user approved!)       │
│                        │      refresh_token  │                         │
│                        │      expires_in     │                         │
│                        │                     │                         │
│ 5. Store tokens        │                     │                         │
│    securely            │                     │                         │
│ 6. Auto-refresh on 401 │                     │                         │
└────────────────────────┘                     └─────────────────────────┘

The user opens the verification URL in any browser (including their phone, separate from the device running the tool), enters the user code, and approves the request. The tool. Which was polling. Receives the access token on the next poll attempt. The flow is standardized in RFC 8628.

PKCE: why it matters

Device flow uses PKCE (Proof Key for Code Exchange, RFC 7636) to prevent token interception. The tool generates a random code_verifier at the start of the flow (a 43-128-character random string) and includes a SHA-256 hash of it (code_challenge) in the initial request. When exchanging the device code for a token, the tool sends the verifier. The auth server verifies that SHA256(verifier) == challenge.

PKCE matters because the device code travels through the user's browser to reach the auth server. Without PKCE, anyone who intercepts the device code can exchange it for tokens. With PKCE, intercepting the device code is worthless without the verifier, which never leaves the tool's memory. This closes a class of network-attacker scenarios that were viable against the original OAuth device flow specification (pre-2.1).

The cost of PKCE is one extra parameter on the first request and one extra parameter on the token exchange. It is, unambiguously, table stakes for any new OAuth implementation in 2026.

Implementation: from zero to authenticated

A complete Node.js implementation of the device flow from the client side. Drop this into a CLI project and you have working OAuth auth in ~50 lines:

import crypto from "node:crypto";

const CLIENT_ID = "your_papermark_app_client_id";
const SCOPES = [
  "datarooms.read",
  "datarooms.write",
  "documents.read",
  "documents.write",
  "links.write",
  "analytics.read",
  "offline_access", // needed for refresh tokens
].join(" ");

function pkce() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto
    .createHash("sha256")
    .update(verifier)
    .digest("base64url");
  return { verifier, challenge };
}

async function startDeviceFlow() {
  const { verifier, challenge } = pkce();

  // 1. Request a device code
  const initRes = await fetch("https://api.papermark.com/oauth/device/code", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      code_challenge: challenge,
      code_challenge_method: "S256",
    }),
  });

  if (!initRes.ok) {
    throw new Error(`device code request failed: ${initRes.status}`);
  }
  const init = await initRes.json();
  // init = {
  //   device_code:               "GhvxxxFOO…",
  //   user_code:                 "WDJB-MJHT",
  //   verification_uri:          "https://app.papermark.com/oauth/device",
  //   verification_uri_complete: "https://app.papermark.com/oauth/device?user_code=WDJB-MJHT",
  //   expires_in:                900,   // seconds until device_code expires
  //   interval:                  5      // poll interval in seconds
  // }

  console.log(`\nOpen this URL in your browser:`);
  console.log(`  ${init.verification_uri}`);
  console.log(`\nEnter code: ${init.user_code}\n`);

  // Show the complete URL too — most users prefer one click
  console.log(`Or open this URL directly: ${init.verification_uri_complete}\n`);

  // 2. Poll for the token
  let interval = init.interval;
  const deadline = Date.now() + init.expires_in * 1000;

  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, interval * 1000));

    const tokRes = await fetch("https://api.papermark.com/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "urn:ietf:params:oauth:grant-type:device_code",
        device_code: init.device_code,
        client_id: CLIENT_ID,
        code_verifier: verifier,
      }),
    });
    const tok = await tokRes.json();

    if (tok.error === "authorization_pending") continue;
    if (tok.error === "slow_down") {
      interval += 5;
      continue;
    }
    if (tok.error === "expired_token") {
      throw new Error("device code expired — re-run login");
    }
    if (tok.error === "access_denied") {
      throw new Error("user denied the request");
    }
    if (tok.error) {
      throw new Error(tok.error_description ?? tok.error);
    }

    // Success
    return {
      access_token: tok.access_token,
      refresh_token: tok.refresh_token,
      expires_at: Date.now() + tok.expires_in * 1000,
      scope: tok.scope,
    };
  }

  throw new Error("device code expired before user approved");
}

That's the whole client-side. Run it from your CLI's login subcommand, persist the result, and you're done.

Auto-refresh on 401

The whole point of offline_access is that you don't have to re-authenticate the user every time the access token expires (typically every 60 minutes). Wrap your API client with a refresh interceptor that checks the expiry before every call and refreshes proactively:

type Creds = {
  access_token: string;
  refresh_token: string;
  expires_at: number; // ms epoch
};

async function refreshIfNeeded(creds: Creds): Promise<Creds> {
  // Refresh 60s before actual expiry to avoid race conditions
  if (Date.now() < creds.expires_at - 60_000) return creds;

  const r = await fetch("https://api.papermark.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: creds.refresh_token,
      client_id: CLIENT_ID,
    }),
  });

  if (!r.ok) {
    // Refresh token expired or revoked — force re-login
    throw new Error("refresh failed — please re-run papermark login");
  }

  const tok = await r.json();

  return {
    access_token: tok.access_token,
    // Some servers issue a new refresh token on each refresh; some don't
    refresh_token: tok.refresh_token ?? creds.refresh_token,
    expires_at: Date.now() + tok.expires_in * 1000,
  };
}

async function callAPI(path: string, init: RequestInit, creds: Creds) {
  const fresh = await refreshIfNeeded(creds);
  const r = await fetch(`https://api.papermark.com/v1${path}`, {
    ...init,
    headers: {
      ...init.headers,
      Authorization: `Bearer ${fresh.access_token}`,
    },
  });

  // Belt-and-suspenders: if the server says 401 anyway, force refresh once
  if (r.status === 401) {
    creds.expires_at = 0; // force refresh on next refreshIfNeeded
    return callAPI(path, init, creds);
  }

  return r;
}

Persist creds to ~/.config/yourtool/config.json with 0600 permissions so they're readable only by the user. On macOS, consider Keychain for the refresh token specifically (most CLIs don't bother; this is a defense-in-depth measure that matters more for high-privilege scopes).

Where the Papermark CLI keeps credentials

The Papermark CLI resolves tokens in this order. First match wins:

  1. PAPERMARK_TOKEN environment variable (used in CI; takes precedence over everything).
  2. PAPERMARK_CREDENTIALS_FILE pointing to a JSON file (used in Kubernetes/Vault deployments where the file is mounted from a secret).
  3. ~/.config/papermark/config.json (the device-flow target; created by papermark login).

In CI, you'd set PAPERMARK_TOKEN directly from a GitHub Actions secret, AWS Secrets Manager, or Vault. In dev, papermark login populates the config file with refresh-able creds.

Real-world gotchas

The OAuth specification is well-written but implementing it from scratch surfaces a long tail of small issues. Things to know before shipping:

  1. Don't show the user code in URLs you log. It's a one-time secret. Logging it in CI output makes it discoverable to anyone with log read access. Echo it to stdout for the user, don't include it in structured logs.
  2. Respect slow_down from the token endpoint. If you ignore it and continue polling at the original rate, you'll get rate-limited and eventually rejected. The interval should be additive. Start at 5s, bump to 10s on slow_down, then 15s, etc.
  3. Don't poll faster than the interval value the server returned. Same reason. Some servers will rate-limit aggressively if you do.
  4. Bind tokens to scopes at the start. Request only what you need. Re-issuing a token with broader scopes requires a re-auth flow that interrupts the user. Over-scoping at start is the path to "we have to call this *.delete-scoped agent quietly removing things."
  5. Encrypt the refresh token at rest if your tool runs in shared environments. Refresh tokens are long-lived secrets. Typically 90 days for Papermark, sometimes longer. macOS Keychain, Windows Credential Manager, Linux Secret Service API all work.
  6. Don't open the browser automatically without telling the user. Some tools shell out to open or xdg-open to open the verification URL. This breaks for SSH sessions, headless environments, and locked-down workstations. Always print the URL too.
  7. Handle clock skew. The expires_at you compute locally and the server's view of expiry can drift by a few seconds. Always refresh slightly early (60s buffer is conservative).
  8. Treat the refresh token as PII for log purposes. Don't log it, don't put it in error reports, don't include it in support-ticket attachments. Anyone who steals a refresh token has 90 days of access.
  9. Implement papermark logout properly. Revoke the refresh token server-side via POST /oauth/revoke, then delete the local file. Just deleting the file leaves the refresh token valid until natural expiry.
  10. Document the offline_access scope explicitly. Some users (and security reviewers) want to know whether your tool retains long-lived credentials. The answer is yes if you requested offline_access. Be honest about it.

A note on the difference between OAuth 2.0 and OAuth 2.1

OAuth 2.0 was published in 2012; OAuth 2.1 is a consolidation draft of best practices that emerged over the following decade. The key practical differences relevant to device flow:

  1. PKCE becomes mandatory (it was strongly recommended but optional in 2.0).
  2. Implicit grant is removed entirely (it was already deprecated by 2019).
  3. Refresh tokens for public clients must be sender-constrained (PKCE-bound) or rotated on use.
  4. URL fragments are no longer used to carry access tokens (a 2.0-era anti-pattern).
  5. Various security guidance from RFC 6819 (OAuth Threat Model) and RFC 8252 (OAuth for Native Apps) is now incorporated as normative.

If you're starting fresh in 2026, target OAuth 2.1. The spec is shorter, the surface is smaller, and the security defaults are better.

See also

More in Engineering