In this article
July 30, 2025
July 30, 2025

How to add auth to your Go CLI using WorkOS

Authenticate users in your Go 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.

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 Go CLI using WorkOS, step by step.

What we will build

You’ll create a Go 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:

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.

  
func requestDeviceCode() (map[string]interface{}, error) {
    resp, err := http.PostForm(
        "https://api.workos.com/user_management/authorize/device",
        url.Values{"client_id": {"APP_CLIENT_ID"}},
    )
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var data map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&data)
    return data, nil
}
  

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
}
  

Step 2: Prompt the user to authenticate

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

  
func promptUserToAuthenticate(data map[string]interface{}) {
    fmt.Println("\nTo sign in")
    fmt.Printf("\nVisit: %s\n", data["verification_uri"])
    fmt.Printf("Enter code: %s\n\n", data["user_code"])
    fmt.Printf("Or open: %s\n", data["verification_uri_complete"])

    exec.Command("open", data["verification_uri_complete"].(string)).Start()
}
  

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.

  
func pollForTokens(clientID, deviceCode string, expiresIn, interval int) (map[string]interface{}, error) {
    deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)

    for time.Now().Before(deadline) {
        resp, err := http.PostForm(
            "https://api.workos.com/user_management/authenticate",
            url.Values{
                "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
                "device_code": {deviceCode},
                "client_id": {clientID},
            },
        )
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()

        var data map[string]interface{}
        json.NewDecoder(resp.Body).Decode(&data)

        if resp.StatusCode == http.StatusOK {
            return data, nil
        }

        switch data["error"] {
        case "authorization_pending":
            time.Sleep(time.Duration(interval) * time.Second)
        case "slow_down":
            interval++
            time.Sleep(time.Duration(interval) * time.Second)
        case "access_denied", "expired_token":
            return nil, fmt.Errorf("authorization failed")
        default:
            return nil, fmt.Errorf("authorization failed")
        }
    }
    return nil, fmt.Errorf("authorization timed out")
}
  

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.

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

  
func main() {
    requestData, err := requestDeviceCode()
    if err != nil {
        log.Fatal("Failed to start device authorization:", err)
    }

    promptUserToAuthenticate(requestData)

    // Run the poll in a separate goroutine for async behavior
    done := make(chan map[string]interface{})
    go func() {
        responseData, err := pollForTokens(
            "APP_CLIENT_ID",
            requestData["device_code"].(string),
            int(requestData["expires_in"].(float64)),
            int(requestData["interval"].(float64)),
        )
        if err != nil {
            log.Fatal("Login failed:", err)
        }
        done <- responseData
    }()

    // Wait for the goroutine to finish
    responseData := <-done
    fmt.Println("\nAuthentication successful!")
    fmt.Println("Access Token:", responseData["access_token"])
}
  

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.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.