Authenticating Clerk Users in a Rust CLI
How to let your CLI log people in with Google or GitHub using Clerk
Heads-up: this post is long because it’s the entire playbook. Copy-paste the code blocks into your own project and you’ll be shipping in an afternoon.
The Problem in One
CLIs don’t have a browser, but Clerk needs a browser to sign you in. We’ll have to work around this.
1. The Architecture, Drawn on a Napkin
┌─────────────┐ ┌─────────────┐│ mycli auth │ 1. starts │ 127.0.0.1:0 ││ login ├───────────────►│ local http │└─────────────┘ │ server │ └─────┬───────┘ │ 4. token in query ▼ ┌─────────────┐ │ /cli-auth │ │ Next.js │ │ page │ └─────┬───────┘ │ 2. open browser ▼ ┌─────────────┐ │ Clerk │ │ <SignIn/> │ └─────────────┘
- CLI spins up a one-shot web server on a random port.
- CLI opens the browser to your
/cli-auth
page with?port=12345
. - User logs in with Google/GitHub via Clerk.
- Your backend mints a 7-day JWT and redirects back to
http://127.0.0.1:12345/callback?token=eyJ...
. - CLI catches the token, stores it somewhere (OS keychain), and shuts down the server.
- CLI uses the token to authenticate requests to your backend.
- Profit ???
2. CLI Skeleton (clap)
#[derive(Parser)]#[command(name = "mycli")]struct Cli { #[command(subcommand)] command: Commands,}
#[derive(Subcommand)]enum Commands { Auth(Auth),}
#[derive(Subcommand)]enum Auth { Login, Logout, Status,}
3. Login Flow (Server in a CLI)
pub async fn execute() -> Result<()> { // 1. Ephemeral server let listener = TcpListener::bind("127.0.0.1:0").await?; let port = listener.local_addr()?.port();
let (tx, rx) = oneshot::channel::<String>(); let tx = Arc::new(Mutex::new(Some(tx)));
let app = Router::new().route( "/callback", get(move |Query(q): Query<Callback>| { if let Some(tx) = tx.lock().unwrap().take() { let _ = tx.send(q.token); } "You can close this tab ✔" }), );
tokio::spawn(async move { axum::serve(listener, app).await });
// 2. Open browser let url = format!("http://localhost:3000/cli-auth?port={}", port); webbrowser::open(&url)?;
// 3. Wait for token let token = rx.await.context("login cancelled")?; session::store_token(&token)?; println!("🎉 logged in"); Ok(())}
4. Secure Storage (using keyring)
const SERVICE: &str = "com.example.mycli";
pub fn store_token(token: &str) -> Result<()> { Entry::new(SERVICE, "session")?.set_password(token)?; Ok(())}
pub fn retrieve_token() -> Result<String> { Entry::new(SERVICE, "session")?.get_password().map_err(|_| anyhow!("not logged in"))}
pub fn delete_token() -> Result<()> { let _ = Entry::new(SERVICE, "session")?.delete_password(); Ok(())}
- macOS → Keychain
- Windows → Credential Manager
- Linux → Secret Service.
5. Companion Next.js Page
'use client';
import { SignIn, useSession } from '@clerk/nextjs';import { useSearchParams } from 'next/navigation';import { useEffect, useState } from 'react';
export default function CliAuthPage() { const { session } = useSession(); const searchParams = useSearchParams(); const [error, setError] = useState<string | null>(null); const port = searchParams.get('port');
useEffect(() => { const port = searchParams.get('port');
if (!port) { setError('Missing port parameter in the URL.'); return; }
if (session) { fetch(`/api/auth/cli-token`) .then((res) => { if (!res.ok) { throw new Error('Failed to generate CLI token.'); } return res.json(); }) .then((data) => { const token = data.token; const redirectUrl = `http://127.0.0.1:${port}/callback?token=${token}`; window.location.href = redirectUrl; }) .catch((err) => { setError(err.message); }); } }, [session, searchParams]);
if (error) { return ( <div className='flex min-h-screen flex-col items-center justify-center'> <p className='text-red-500'>{error}</p> </div> ); }
if (session) { return ( <div className='flex min-h-screen flex-col items-center justify-center'> <p>Authenticating, please wait...</p> </div> ); }
return ( <div className='flex min-h-screen flex-col items-center justify-center'> <h1 className='mb-4 text-2xl font-bold'>Sign in to the CLI</h1> <SignIn forceRedirectUrl={`/cli-auth?port=${port}`} /> </div> );}
6. Backend Endpoint (Next.js API Route)
import { auth } from '@clerk/nextjs/server';import { NextResponse } from 'next/server';
export async function GET() { try { const { userId, getToken } = await auth();
if (!userId) { return NextResponse.json( { error: 'Unauthorized (user not found)' }, { status: 401 } ); }
// This template must be created in the Clerk Dashboard const templateName = 'cli_session_token';
const token = await getToken({ template: templateName });
return NextResponse.json({ token }); } catch (error) { return NextResponse.json( { error: 'Internal Server Error' }, { status: 500 } ); }}
7. Using the Token in the CLI against your API
pub async fn list_resources() -> Result<()> { let token = session::retrieve_token()?; let res = reqwest::Client::new() .get("http://localhost:8000/api/data") .bearer_auth(token) .send() .await?; println!("{:#}", res.json::<serde_json::Value>().await?); Ok(())}
8. Logout—One Line
session::delete_token()?;println!("logged out");
Recap in 60 Seconds
- Clerk handles the messy OAuth dance in the browser.
- We hand the CLI a long-lived JWT via a localhost callback.
- Token is stored locally, preferably in the OS keychain.
- Backend validates the JWT automatically with
clerk-rs
middleware.
It took my 3 days to scour information how to build cli auth with clerk. Hope this will save you some time and effort.