Skip to main content
  1. Languages/
  2. Rust Guides/

Secure Your Rust App: A Complete Guide to Implementing OAuth2 with Axum

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

Authentication is the gatekeeper of the web. In the early days, we rolled our own login forms, hashed passwords (hopefully with salt), and managed sessions manually. But in the modern landscape of 2025, handling user credentials directly is often a liability you don’t need.

OAuth2 has become the industry standard for delegation and authentication. It allows your users to log in using their existing identities (GitHub, Google, Microsoft, etc.) without trusting your application with their passwords.

In this guide, we are going to build a production-ready OAuth2 authentication flow using Rust, the Axum web framework, and the oauth2 crate. We aren’t just writing “Hello World” here; we are building a foundation you can actually use in your microservices or SaaS platforms.

Why Axum and OAuth2?
#

Before we dive into the code, let’s establish why this specific stack is dominating the Rust web ecosystem right now.

Axum has solidified its position as the go-to web framework for many Rust developers due to its ergonomic API and deep integration with the Tokio ecosystem. When paired with the oauth2 crate—a comprehensive, type-safe implementation of the OAuth2 spec—you get a system that is both performant and strictly compliant with security standards.

What You Will Learn
#

  1. How the OAuth2 Authorization Code Grant flow works.
  2. Setting up a robust Rust development environment for web apps.
  3. Implementing the login redirection logic.
  4. Handling the callback and exchanging codes for tokens securely.
  5. Best practices for session management and preventing CSRF attacks.

The OAuth2 Flow Visualization
#

Understanding the “dance” between the user, your server, and the provider is crucial. We will be implementing the Authorization Code Grant, which is the gold standard for server-side applications.

Here is how the data flows:

sequenceDiagram autonumber participant User as User (Browser) participant App as Rust Server (Axum) participant Provider as Auth Provider (e.g., GitHub) Note over User, App: Phase 1: Initiation User->>App: Visits /login App->>App: Generate CSRF State & PKCE Verifier App->>User: Redirects to Provider (client_id, scope, state) Note over User, Provider: Phase 2: User Consent User->>Provider: Logs in and Approves Access Provider->>User: Redirects to Callback URL (auth_code, state) Note over User, App: Phase 3: Exchange & Session User->>App: GET /auth/callback?code=XYZ&state=ABC App->>App: Verify CSRF State App->>Provider: POST /token (auth_code, client_secret) Provider->>App: Returns Access Token & Refresh Token App->>User: Sets HTTP-Only Cookie / Redirects to Dashboard

1. Prerequisites and Environment Setup
#

To follow along, ensure you have the following:

  • Rust: Version 1.75 or higher (we want those async traits features).
  • An OAuth2 Provider App: You need a Client ID and Client Secret. For this tutorial, we will simulate a GitHub integration, but the code works for Google, Auth0, or any OIDC provider.
    • GitHub Setup: Settings -> Developer Settings -> OAuth Apps -> New OAuth App.
    • Callback URL: Set this to http://localhost:3000/auth/callback.

Project Initialization
#

Let’s create a new project and add our dependencies. We are using tokio for our runtime, axum for the server, serde for JSON handling, and dotenvy to manage secrets.

cargo new rust-oauth-demo
cd rust-oauth-demo

Update your Cargo.toml:

[package]
name = "rust-oauth-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
oauth2 = "4.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
reqwest = { version = "0.11", features = ["json"] } # For fetching user info

Configuration Management
#

Never hardcode your secrets. Create a .env file in your project root:

# .env
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
AUTH_REDIRECT_URL=http://localhost:3000/auth/callback

2. Structuring the OAuth Client
#

We need a factory function that creates our OAuth client based on environment variables. This keeps our route handlers clean.

Create a file src/oauth_client.rs (or just put it in main for simplicity, but let’s be professional):

// src/main.rs

use oauth2::{
    basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl,
};
use std::env;

pub fn oauth_client() -> BasicClient {
    let client_id = env::var("GITHUB_CLIENT_ID")
        .expect("Missing GITHUB_CLIENT_ID");
    let client_secret = env::var("GITHUB_CLIENT_SECRET")
        .expect("Missing GITHUB_CLIENT_SECRET");
    let redirect_url = env::var("AUTH_REDIRECT_URL")
        .expect("Missing AUTH_REDIRECT_URL");

    let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string())
        .expect("Invalid authorization endpoint URL");
    let token_url = TokenUrl::new("https://github.com/login/oauth/access_token".to_string())
        .expect("Invalid token endpoint URL");

    BasicClient::new(
        ClientId::new(client_id),
        Some(ClientSecret::new(client_secret)),
        auth_url,
        Some(token_url),
    )
    .set_redirect_uri(RedirectUrl::new(redirect_url).expect("Invalid redirect URL"))
}

Note: If you are using Google or generic OIDC, you might need discovery endpoints, but hardcoding known endpoints for GitHub is standard practice.


3. Implementing the Login Route
#

This route initiates the flow. It generates a unique CSRF token (CsrfToken), constructs the authorization URL, and redirects the user.

In a real-world scenario, you must store the CsrfToken (usually in a Redis cache or a secure cookie) to verify it when the user returns. For this tutorial, strictly to keep code runnable without external databases, we will rely on a simple memory map or assume statelessness for the demonstration (though we will discuss the production fix).

use axum::{
    extract::Query,
    response::{IntoResponse, Redirect},
    routing::get,
    Router,
    Extension,
};
use oauth2::{CsrfToken, Scope};
use serde::Deserialize;
use std::net::SocketAddr;
use tokio::net::TcpListener;

// Include the client function we wrote above
// mod oauth_client; 

#[tokio::main]
async fn main() {
    // Load .env
    dotenvy::dotenv().ok();
    
    // Initialize logging
    tracing_subscriber::fmt::init();

    let app = Router::new()
        .route("/", get(index))
        .route("/auth/login", get(login_handler))
        .route("/auth/callback", get(auth_callback));

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    let listener = TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn index() -> &'static str {
    "Welcome to the Rust OAuth2 Demo! Go to /auth/login to start."
}

async fn login_handler() -> impl IntoResponse {
    let client = oauth_client(); // Using the helper function

    // Generate the authorization URL
    let (auth_url, _csrf_token) = client
        .authorize_url(CsrfToken::new_random)
        .add_scope(Scope::new("read:user".to_string())) // Request permission to read user profile
        .add_scope(Scope::new("user:email".to_string()))
        .url();

    // In a production app, STORE _csrf_token in a session/cookie here!
    // We will verify it in the callback.
    
    Redirect::to(auth_url.as_str())
}

4. Handling the Callback
#

This is where the magic (and the danger) happens. The provider redirects the user back to us with a code and a state. We must:

  1. Extract these parameters.
  2. Exchange the code for an AccessToken.
  3. Use the token to fetch user details.
use axum::http::StatusCode;
use oauth2::{AuthorizationCode, TokenResponse};

#[derive(Debug, Deserialize)]
struct AuthRequest {
    code: String,
    state: String,
}

// Struct to deserialize the User info from GitHub
#[derive(Debug, Deserialize, serde::Serialize)]
struct UserInfo {
    login: String,
    id: u64,
    avatar_url: String,
    name: Option<String>,
}

async fn auth_callback(Query(query): Query<AuthRequest>) -> impl IntoResponse {
    let client = oauth_client();

    // 1. Exchange the code with a token.
    // This sends a POST request to GitHub's token endpoint.
    let token_result = client
        .exchange_code(AuthorizationCode::new(query.code.clone()))
        .request_async(oauth2::reqwest::async_http_client)
        .await;

    let token = match token_result {
        Ok(t) => t,
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Token exchange failed: {}", e)).into_response(),
    };

    let access_token = token.access_token().secret();

    // 2. Use the token to get user info
    let http_client = reqwest::Client::new();
    let user_info_resp = http_client
        .get("https://api.github.com/user")
        .header("User-Agent", "rust-oauth-demo") // GitHub requires a User-Agent
        .header("Authorization", format!("Bearer {}", access_token))
        .send()
        .await;

    match user_info_resp {
        Ok(resp) => {
            if resp.status().is_success() {
                let user_info: UserInfo = resp.json().await.unwrap();
                // Success! In a real app, you would create a session JWT here
                // and set it as a cookie.
                (StatusCode::OK, format!("Login Successful! Welcome, {} (ID: {})", user_info.login, user_info.id)).into_response()
            } else {
                (StatusCode::UNAUTHORIZED, "Failed to fetch user info").into_response()
            }
        }
        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Request to GitHub failed").into_response(),
    }
}

5. Security Analysis & Best Practices
#

Implementing OAuth2 is safer than handling passwords, but it introduces its own complexity. Let’s compare the methods.

Authentication Strategy Comparison
#

Feature Basic Auth / Passwords Session Cookies OAuth2 / OIDC
Credential Risk High (Server stores hashed passwords) Medium Low (Server never sees password)
Implementation Easy Medium Complex
User Experience Friction (New password to remember) Good Excellent (One-click login)
Revocation Hard (Force password reset) Easy (Delete session) Easy (Revoke token)

Common Pitfalls and Solutions
#

1. CSRF Attacks (The state parameter)
#

In the code above, we generated a _csrf_token but didn’t verify it for the sake of brevity. The Fix: You must store the generated CSRF token in a temporary, HTTP-only cookie before redirecting the user. Inside auth_callback, read that cookie and compare it with the state query parameter. If they don’t match, abort immediately.

2. Storing Access Tokens
#

Do not send the raw access token (from GitHub) to the frontend client. If your front-end is compromised (XSS), the attacker gains access to the user’s GitHub account. The Fix: Create your own Session ID (or a signed JWT). Store the GitHub Access Token in your database (encrypted) linked to that Session ID. Give the user an HTTP-Only cookie containing your Session ID.

3. Scope Creep
#

Only ask for the permissions you strictly need. The Fix: Start with read:user. Do not ask for repo (write access) unless your app specifically pushes code on behalf of the user.


6. Going Further: Production Considerations
#

As we move through 2025, security requirements are tightening. Here are the “Senior Dev” touches you should add to this implementation:

  1. PKCE (Proof Key for Code Exchange): While originally for mobile apps, PKCE is now recommended for all clients. The oauth2 crate supports this. It adds a cryptographic challenge to the flow, preventing code interception attacks.
  2. Async/Await Robustness: Ensure your reqwest clients utilize connection pooling. Creating a new client for every request is an anti-pattern in high-load Rust applications.
  3. Error Handling: Replace the .unwrap() calls in the demo with proper anyhow or thiserror handling to ensure your server doesn’t panic on malformed external responses.

Conclusion
#

You have now built a functioning OAuth2 flow in Rust. We utilized Axum for the web layer and oauth2 for the heavy lifting of the protocol.

This approach delegates the hardest part of security—credential management—to providers like Google or GitHub, who have teams dedicated to it. Your responsibility is now ensuring the exchange and session management are secure.

Rust’s type system helps significantly here; the oauth2 crate makes it difficult to accidentally mix up a Client ID with a Secret or send a request without a token.

Next Steps:

  • Implement a database layer (SQLx or Diesel) to save users.
  • Add a middleware layer in Axum to protect private routes using the session cookie.
  • Explore OpenID Connect (OIDC) for getting standardized identity information.

Happy coding, and keep your crates updated!