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.
OAuth loopback works by using a special redirect URI (like http://127.0.0.1:PORT/callback) to send authorization codes directly back to an app, which listens on a local port, avoiding browser-based callbacks and enabling secure, direct communication for token exchange. Instead of a web redirect, the app opens a local listener, you can auth in a browser, and the server redirects back to the app's local URI, letting the app grab the code and exchange it for a token. It's particularly useful for development scenarios.
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:
StartAuthFlowresolves the user's handle, discovers their PDS, sends a Pushed Authorization Request (PAR), and returns an authorization URL.- The user's browser opens to a consent screen.
- After the user approves, their PDS redirects back to
http://127.0.0.1:<port>/callbackwith 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
atprotoscope. 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!