skip to content

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/> │
└─────────────┘
  1. CLI spins up a one-shot web server on a random port.
  2. CLI opens the browser to your /cli-auth page with ?port=12345.
  3. User logs in with Google/GitHub via Clerk.
  4. Your backend mints a 7-day JWT and redirects back to http://127.0.0.1:12345/callback?token=eyJ....
  5. CLI catches the token, stores it somewhere (OS keychain), and shuts down the server.
  6. CLI uses the token to authenticate requests to your backend.
  7. 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)

login_flow.rs
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)

session.rs
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

app/cli-auth.tsx
'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)

pages/api/cli-token.ts
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

  1. Clerk handles the messy OAuth dance in the browser.
  2. We hand the CLI a long-lived JWT via a localhost callback.
  3. Token is stored locally, preferably in the OS keychain.
  4. 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.