OAuth with Go Tutorial

Authenticate with atproto OAuth from a Go app

In this tutorial, you'll build a minimal Go tool that authenticates a user with atproto OAuth and fetches their session info. The entire app fits in a single file.

This is a good starting point for understanding how OAuth works in AT Protocol. For a TypeScript version, see the OAuth with Node Tutorial. For building production web apps, see the OAuth with NextJS Tutorial.

Prerequisites

You should have a working understanding of Go.

You should have installed:

  • Go 1.24+

On platforms supported by homebrew, you can install it with:

brew install go

You should also have an atproto handle to test with.

Part 1: Project Setup

Create a new Go module and install the Go SDK:

mkdir go-oauth-cli
cd go-oauth-cli
go mod init go-oauth-cli
go get github.com/bluesky-social/indigo/atproto/auth/oauth

That's it — the Go SDK includes everything you need for OAuth, including an in-memory session store for development.

Part 2: Build the OAuth Client

Create main.go. Start with the imports and the OAuth client setup:

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"runtime"
	"strings"

	"github.com/bluesky-social/indigo/atproto/auth/oauth"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "Usage: go run main.go <your-handle>")
		os.Exit(1)
	}
	handle := os.Args[1]

	if err := run(context.Background(), handle); err != nil {
		log.Fatal(err)
	}
}

Now add the run function. First, set up a callback server on a random port, then configure the OAuth client:

func run(ctx context.Context, handle string) error {
	// Start the callback server on a random available port
	callbackCh := make(chan url.Values, 1)
	port, server, err := listenForCallback(ctx, callbackCh)
	if err != nil {
		return err
	}
	defer server.Close()

	callbackURL := fmt.Sprintf("http://127.0.0.1:%d/callback", port)

	// Create an OAuth client with localhost config and in-memory storage
	config := oauth.NewLocalhostConfig(callbackURL, []string{"atproto"})
	store := oauth.NewMemStore()
	oauthClient := oauth.NewClientApp(&config, store)

NewLocalhostConfig sets up a loopback client.

NewMemStore provides in-memory session storage for development.

Part 3: Login Flow

Continue the run function with the login flow. This resolves the handle, opens the browser, and waits for the callback:

	// Start the OAuth flow
	fmt.Printf("Logging in as %s...\n", handle)
	authURL, err := oauthClient.StartAuthFlow(ctx, handle)
	if err != nil {
		return fmt.Errorf("starting auth flow: %w", err)
	}

	// Open the browser to the authorization URL
	fmt.Printf("Opening browser...\n")
	if !strings.HasPrefix(authURL, "https://") {
		return fmt.Errorf("unexpected non-https auth URL")
	}
	if err := openBrowser(authURL); err != nil {
		fmt.Printf("Could not open browser automatically.\nPlease visit: %s\n", authURL)
	}

	// Wait for the OAuth callback
	fmt.Println("Waiting for authorization...")
	params := <-callbackCh

The flow works like this:

  1. StartAuthFlow resolves the user's handle, discovers their PDS, sends a Pushed Authorization Request (PAR), and returns an authorization URL.
  2. The user's browser opens to a consent screen.
  3. After the user approves, their PDS redirects back to http://127.0.0.1:<port>/callback with an authorization code.

Part 4: Use the Session

Complete the run function by exchanging the callback for a session and making an authenticated API call:

	// Exchange the authorization code for a session
	sessData, err := oauthClient.ProcessCallback(ctx, params)
	if err != nil {
		return fmt.Errorf("processing callback: %w", err)
	}
	fmt.Printf("Logged in! DID: %s\n", sessData.AccountDID)

	// Resume the session to get an API client
	session, err := oauthClient.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
	if err != nil {
		return fmt.Errorf("resuming session: %w", err)
	}

	// Fetch the user's session info to prove it works
	client := session.APIClient()
	var resp struct {
		DID    string `json:"did"`
		Handle string `json:"handle"`
	}
	if err := client.Get(ctx, "com.atproto.server.getSession", nil, &resp); err != nil {
		return fmt.Errorf("fetching session: %w", err)
	}

	fmt.Printf("\nSession:\n")
	fmt.Printf("  Handle: %s\n", resp.Handle)
	fmt.Printf("  DID:    %s\n", resp.DID)
	fmt.Printf("  Host:   %s\n", sessData.HostURL)

	return nil
}

ProcessCallback exchanges the authorization code for tokens. ResumeSession wraps those tokens in a session that handles DPoP signing and token refresh automatically. APIClient() returns an HTTP client pre-configured with the user's credentials — you can use it to make any XRPC call.

Part 5: Helper Functions

Add the callback server and browser-opening helpers:

func listenForCallback(ctx context.Context, res chan url.Values) (int, *http.Server, error) {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return 0, nil, err
	}

	mux := http.NewServeMux()
	server := &http.Server{Handler: mux}

	mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
		res <- r.URL.Query()
		w.Header().Set("Content-Type", "text/html")
		w.WriteHeader(200)
		w.Write([]byte("<h1>Authorized! You can close this tab.</h1>"))
		go server.Shutdown(ctx)
	})

	go func() {
		if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()

	return listener.Addr().(*net.TCPAddr).Port, server, nil
}

func openBrowser(url string) error {
	switch runtime.GOOS {
	case "darwin":
		return exec.Command("open", url).Run()
	case "windows":
		return exec.Command("cmd", "/c", "start", url).Run()
	default:
		return exec.Command("xdg-open", url).Run()
	}
}

The callback server listens on 127.0.0.1:0, which tells the OS to assign any available port. When the callback arrives, it sends the query parameters through a channel back to the main flow and shuts itself down.

Part 6: Run It

Run your CLI tool, passing your handle as an argument:

go run main.go your-handle.bsky.social

Your browser should open to a consent screen. After you approve, the CLI will print your session info:

Logging in as your-handle.bsky.social...
Opening browser...
Waiting for authorization...
Logged in! DID: did:plc:xxxxx

Session:
  Handle: your-handle.bsky.social
  DID:    did:plc:xxxxx
  Host:   https://shimeji.us-east.host.bsky.network

Conclusion

You've built a minimal OAuth CLI tool in Go that authenticates with the AT Protocol. The full source is about 120 lines of code.

A few things to keep in mind:

  • This is for local development. The localhost client won't work in production. For a production-ready Go example with SQLite persistence and multiple commands, see the go-oauth-cli-app in the Cookbook.
  • Sessions are in-memory. Every run requires re-authorization. The Cookbook example shows how to persist sessions with SQLite.
  • Scopes. This tutorial requests only the base atproto scope. To read or write data on behalf of the user, you'll need additional scopes.

You can find more guides and tutorials in our Docs, and more example apps in the Cookbook repository. Happy building!