In this article
April 28, 2026
April 28, 2026

How to add auth to your Rust CLI using WorkOS

Authenticate users in your Rust command-line tool with a secure OAuth 2.0 Device Code flow using WorkOS. This tutorial shows how to implement login via the terminal, step by step.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Modern CLI apps often need to authenticate users, and using a simple web-based OAuth flow is the cleanest way to do it.

In this tutorial, we'll implement a CLI login flow using WorkOS CLI Auth to authenticate users from the command line by using a web browser. Based on the OAuth 2.0 Device Authorization Grant, this method is perfect for CLIs where embedding a browser isn't feasible.

In this guide, we'll walk through how to add auth to your Rust CLI using WorkOS, step by step.

What we will build

You'll create a Rust CLI that:

  1. Requests a device code from WorkOS: The CLI starts the login flow by contacting WorkOS to get a device_code (for itself) and a user_code (for the user), along with a URL the user should visit.
  2. Displays a user-friendly prompt: The CLI prints the user_code and verification URL in the terminal, and optionally opens the browser automatically for the user to log in and approve.
  3. Polls WorkOS while the user logs in: As the user completes authentication in the browser, the CLI quietly checks in with WorkOS every few seconds to see if the user has completed the login.
  4. Exchanges the device code for tokens: Once authorized, the CLI receives an access token ready to make authenticated API calls.

This flow is ideal for tools running in environments that can't easily embed a browser window (like the terminal), and it avoids the need to paste auth tokens manually.

Let's dive in.

Prerequisites

Before starting, make sure you have:

  • A WorkOS account (first 1 million active users free, no credit card required).
  • A CLI app registered as a public app. You can configure one in the WorkOS dashboard > Applications. Note down the Client ID.
  • Rust 1.88+ installed (with cargo).
  • You must use AuthKit for your authentication UI (for info on how to customize the look-and-feel see our docs).

Project setup

Create a new project and move into it:

  
cargo new workos-cli-auth
cd workos-cli-auth
  

Add these dependencies to your Cargo.toml:

  
[dependencies]
workos = "1"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
webbrowser = "1"
  

We'll use the official WorkOS Rust SDK for all API communication. It bundles its own HTTP transport and response types, so you don't need to add reqwest or serde separately. We use tokio for the async runtime, anyhow for ergonomic error handling, and webbrowser to open the verification URL.

Alternatively, you can add these from the command line:

  
cargo add workos tokio anyhow webbrowser
  

Step 1: Start the auth flow from the CLI

Start by initiating the device authorization flow from your CLI tool. This will generate a user-facing code and a verification URL.

  
use workos::Client;

async fn request_device_code(client: &Client) -> anyhow::Result<workos::DeviceAuthorizationResponse> {
    let resp = client
        .user_management()
        .authorize_device()
        .await?;
    Ok(resp)
}
  

This function hits the WorkOS Device Authorization endpoint and retrieves:

  • device_code: For your CLI only.
  • user_code: To show the user.
  • verification_uri_complete: A URL users can click to authorize.
  • interval: How often to poll for the token.
  
{
  "device_code": "71azDp28ToiCscGDvLxnXLkuuFRMrnd4V7rdsjIlBPXuy13j8GOzU0aZHb46tsz3",
  "user_code": "RRGQ-BJVS",
  "verification_uri": "https://smart-chefs.authkit.app/device",
  "verification_uri_complete": "https://smart-chefs.authkit.app/device?user_code=ABCD-EFGH",
  "expires_in": 300,
  "interval": 5
}
  

The SDK exports all response types and keeps them in sync with the WorkOS API automatically, no need to define or maintain your own structs.

Step 2: Prompt the user to authenticate

Display the user code and link in the terminal. Optionally, open the browser automatically.

  
fn prompt_user_to_authenticate(data: &workos::DeviceAuthorizationResponse) {
    println!("\nTo sign in");
    println!("\nVisit: {}", data.verification_uri);
    println!("Enter code: {}\n", data.user_code);
    println!("Or open: {}", data.verification_uri_complete);

    let _ = webbrowser::open(&data.verification_uri_complete);
}
  

The webbrowser crate is cross-platform, it picks the right command (open on macOS, xdg-open on Linux, start on Windows) so you don't have to shell out manually. We discard the result with let _ = because failing to open the browser is non-fatal: the user can still copy the URL.

Note the following:

  • Always display the user_code and verification_uri to the user.
  • Never show the device_code, it’s only used internally for polling.

Step 3: Poll for access tokens

While the user authenticates in their browser, your app should poll the token endpoint. The SDK handles the polling loop, error classification, and backoff for you:

  
use std::time::Duration;

async fn poll_for_tokens(
    client: &workos::Client,
    device_code: &str,
    expires_in: u64,
    interval: u64,
) -> anyhow::Result<workos::AuthenticationResponse> {
    let resp = client
        .user_management()
        .authenticate_with_device_code(device_code, expires_in, interval)
        .await?;
    Ok(resp)
}
  

Note the following:

  • Poll at the interval specified in the authorization response (every 5 seconds).
  • Respect slow_down errors by increasing your polling interval.
  • Stop polling when you receive access_denied or expired_token errors.
  • Implement a reasonable timeout to avoid infinite polling.

tokio::time::sleep is non-blocking; while we wait, the runtime is free to do other work. Using match on the error string makes each terminal condition explicit and exhaustive, which the compiler will help enforce.

The response looks like this:

  
{
  "user": {
    "object": "user",
    "id": "user_01JYHX0DW7077GPTAY8MZVNMQX",
    "email": "grant.mccode@workos.com",
    "email_verified": true,
    "first_name": "Grant",
    "last_name": "McCode",
    "profile_picture_url": null,
    "last_sign_in_at": "2025-06-25T19:16:35.647Z",
    "created_at": "2025-06-25T01:20:21.355Z",
    "updated_at": "2025-06-25T19:16:35.647Z",
    "external_id": null
  },
  "organization_id": "org_01JYHNPKWTD5DRGPJHNYBB1HB8",
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkdyYW50IE1jQ29kZSIsImFkbWluIjp0cnVlLCJpYXQiOjEzMzcsInBhc3N3b3JkIjoiaHVudGVyMiJ9.kcmTbx7M89k-3qUXN1UVcy9us6xdPZkDOqQ0UeY3Bws",
  "refresh_token": "RSzR4ngmJROKFJZQEpp5fNF4y",
  "authentication_method": "GoogleOAuth"
}
  

Putting it all together

Wire the three steps into main. The #[tokio::main] attribute sets up the async runtime, and because poll_for_tokens is already an async function we can simply .await it.

  
use workos::Client;

const CLIENT_ID: &str = "APP_CLIENT_ID";

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = Client::builder()
        .api_key(std::env::var("WORKOS_API_KEY").unwrap())
        .client_id(std::env::var("WORKOS_CLIENT_ID").unwrap())
        .build();

    let request_data = request_device_code(&client)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to start device authorization: {e}"))?;

    prompt_user_to_authenticate(&request_data);

    let response_data = poll_for_tokens(
        &client,
        &request_data.device_code,
        request_data.expires_in,
        request_data.interval,
    )
    .await
    .map_err(|e| anyhow::anyhow!("Login failed: {e}"))?;

    println!("\nAuthentication successful!");
    println!("Access Token: {}", response_data.access_token.expose());
    Ok(())
}
  

A couple of things to note:

  • The access_token field is a workos::SecretString. Its Debug output prints "<redacted>" so tokens don't accidentally leak into logs or error reports. Call .expose() when you genuinely need the raw value.
  • If you want polling to happen concurrently with other work, spawn it as a task with tokio::spawn(poll_for_tokens(...)) and .await the resulting JoinHandle when you need the result. For a single-purpose login command, the straight-line .await above is simpler and equivalent.

Run it with:

  
cargo run
  

That’s it! You’ve just added OAuth-powered login to your CLI.

Your users can now authenticate securely through the browser, without ever pasting tokens by hand.