The authorization code flow
You're building a command-line tool that fetches a user's GitHub repositories. Asking for their password is a terrible idea. You need a way to prove your app is allowed to act on their behalf without ever touching their credentials. The authorization code flow solves this. It lets the user log in to the provider, grants your app a temporary ticket, and lets you trade that ticket for a token you can use to make API calls.
OAuth2 is a protocol for delegated access. The authorization code flow is the standard pattern for web apps, CLIs, and mobile apps. You redirect the user to the provider. The user authenticates. The provider sends a one-time code back to your app. You verify the code and exchange it for a token. The token is what you actually attach to requests. The code is useless once exchanged.
How the flow works
Think of the flow like a valet parking ticket. You hand your car keys to the valet. The valet gives you a numbered ticket. You can't drive the car with the ticket, but the ticket proves you own the car. Later, you hand the ticket back to the valet to get your car. An attacker who steals the ticket can't drive the car anywhere else, and the ticket expires if you don't return.
In OAuth2 terms:
- The client is your app.
- The resource owner is the user.
- The authorization server is the provider (GitHub, Google, etc.).
- The authorization code is the valet ticket. It's a one-time secret sent to your redirect URI.
- The access token is the car. It's what you use to access the API.
- The refresh token is a way to get a new access token without bothering the user again.
The flow has four steps. You generate an authorization URL. The user visits it and logs in. The provider redirects to your app with a code. You exchange the code for a token. Every step has security checks. The oauth2 crate enforces most of them at compile time.
Minimal setup
The oauth2 crate provides type-safe wrappers for the protocol. You start by defining the provider's endpoints and your client credentials.
use oauth2::{AuthUrl, Client, ClientId, ClientSecret, CsrfToken, RedirectUrl, TokenUrl};
fn main() {
// The provider's endpoints. These come from the provider's documentation.
// The crate uses strong types to prevent mixing up URLs.
let auth_url = AuthUrl::new("https://example.com/oauth2/auth".to_string()).unwrap();
let token_url = TokenUrl::new("https://example.com/oauth2/token".to_string()).unwrap();
// Create the client with your app's ID and secret.
// The secret stays on your server. Never embed it in a binary or frontend code.
let client = Client::new(
ClientId::new("client_id".to_string()),
Some(ClientSecret::new("client_secret".to_string())),
auth_url,
Some(token_url),
)
// The redirect URI must match exactly what you registered with the provider.
.add_redirect_uri(RedirectUrl::new("http://localhost:8080/callback".to_string()).unwrap());
// Generate the URL the user visits.
// CsrfToken::new_random creates a unique state token to prevent CSRF attacks.
let (auth_url, state) = client.authorize_url(CsrfToken::new_random).unwrap();
println!("Visit this URL to log in: {}", auth_url);
println!("State token: {}", state.secret());
}
The oauth2 crate uses a technique called type state. When you create a Client, the compiler tracks which fields you've set. If you try to call authorize_url without adding a redirect URI, the code won't compile. This prevents a whole class of runtime errors. You can't forget the redirect URI because the method doesn't exist until you add it.
Convention aside: always use CsrfToken::new_random. The crate generates a cryptographically secure random string. Don't roll your own state token. The community expects the explicit CsrfToken type, not a raw string, because it signals intent.
Handling the callback
After the user logs in, the provider redirects to your redirect_uri with a code parameter and the state parameter. Your app must validate the state before proceeding. If the state doesn't match, drop the request. This stops Cross-Site Request Forgery attacks where an attacker forces a user to grant access to their app.
fn handle_callback(
client: Client,
received_code: &str,
received_state: &str,
expected_state: CsrfToken,
) -> oauth2::TokenResponse {
// Verify the state token matches.
// If this check fails, the request might be a CSRF attack.
if received_state != expected_state.secret() {
panic!("State mismatch. Potential CSRF attack.");
}
// Exchange the authorization code for a token.
// The crate handles the request body and signature.
client
.exchange_code(oauth2::AuthorizationCode::new(received_code.to_string()))
.request_async(async_http_client)
.await
.expect("Token exchange failed");
}
The request_async method requires an HTTP client that implements the HttpClient trait. The oauth2 crate is agnostic about the HTTP backend. You pass the client as a function or type.
Convention aside: reqwest is the standard HTTP client in Rust. The oauth2 crate provides a helper oauth2::reqwest::async_http_client that implements the trait. Use this helper unless you have a specific reason to use a different client.
PKCE for public clients
If you're building a CLI tool, a single-page app, or a mobile app, you likely don't have a client secret. These are called public clients. The authorization code flow is vulnerable to interception attacks for public clients. An attacker can intercept the code on the redirect and exchange it for a token.
PKCE (Proof Key for Code Exchange) fixes this. You generate a secret verifier on the client side. You send a challenge derived from the verifier to the provider during the authorization request. When you exchange the code, you send the verifier. The provider checks that the verifier matches the challenge. If an attacker intercepts the code, they can't exchange it because they don't have the verifier.
use oauth2::{
AuthUrl, Client, ClientId, CsrfToken, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, TokenUrl,
};
fn start_pkce_flow() -> (String, CsrfToken, PkceCodeVerifier) {
let auth_url = AuthUrl::new("https://provider.com/auth".to_string()).unwrap();
let token_url = TokenUrl::new("https://provider.com/token".to_string()).unwrap();
// Generate a PKCE challenge and verifier.
// The challenge is sent with the authorization request.
// The verifier is kept secret and sent during the token exchange.
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let client = Client::new(
ClientId::new("public_client_id".to_string()),
None, // Public clients don't have a secret.
auth_url,
Some(token_url),
)
.add_redirect_uri(RedirectUrl::new("http://localhost:8080/callback".to_string()).unwrap());
let (auth_url, state) = client
.authorize_url(CsrfToken::new_random)
.set_pkce_challenge(pkce_challenge)
.unwrap();
println!("Open: {}", auth_url);
(auth_url.to_string(), state, pkce_verifier)
}
When you exchange the code, you must include the verifier.
fn exchange_with_pkce(
client: Client,
code: &str,
verifier: PkceCodeVerifier,
) -> oauth2::TokenResponse {
client
.exchange_code(oauth2::AuthorizationCode::new(code.to_string()))
.set_pkce_verifier(verifier)
.request_async(async_http_client)
.await
.expect("Token exchange failed");
}
PKCE is not optional for public clients. Enable it. The oauth2 crate makes it easy, but you have to call set_pkce_challenge and set_pkce_verifier. If you skip PKCE, you're vulnerable to code interception.
Managing tokens and refresh
Access tokens expire. The TokenResponse contains an access_token and optionally a refresh_token. You use the access token to make API calls. When it expires, you use the refresh token to get a new access token without asking the user to log in again.
The oauth2 crate returns a TokenResponse struct. You need to persist this struct or its fields. When the token expires, you call exchange_refresh_token.
use oauth2::TokenResponse;
fn refresh_token(
client: Client,
refresh_token: oauth2::RefreshToken,
) -> TokenResponse {
// Exchange the refresh token for a new token response.
// The provider may rotate the refresh token, so save the new one.
client
.exchange_refresh_token(&refresh_token)
.request_async(async_http_client)
.await
.expect("Refresh failed");
}
Convention aside: store tokens securely. For a CLI tool, use a secure keyring or an encrypted file. For a web app, use an httpOnly cookie or a secure server-side session. Never store tokens in local storage in a browser. The community expects you to handle persistence. The crate doesn't manage storage for you.
Pitfalls and compiler errors
OAuth2 has many edge cases. The oauth2 crate helps, but you still need to watch for common mistakes.
State validation. If you skip checking the state token, your app is vulnerable to CSRF. The crate generates the state, but you must verify it in the callback handler. If the state doesn't match, abort the flow.
PKCE for public clients. If you build a CLI or SPA, you must use PKCE. The provider might reject the token exchange if PKCE is required but missing. The error will be a runtime HTTP error, not a compile error. Enable PKCE explicitly.
Secret leakage. If you embed the client secret in a binary or frontend code, attackers can steal it. Use environment variables or a secrets manager. The oauth2 crate accepts a ClientSecret, but it doesn't protect the secret. You must protect it.
Scopes. You request scopes during the authorization URL generation. The provider might grant fewer scopes than you requested. The TokenResponse contains the granted scopes. Check them. If you need a scope that wasn't granted, you might need to re-authenticate.
HTTP client trait bounds. If you try to use request_async with an HTTP client that doesn't implement HttpClient, you'll get a compile error. The error looks like E0277 (the trait bound reqwest::Client: oauth2::HttpClient is not satisfied). Use the oauth2::reqwest::async_http_client helper or implement the trait.
Redirect URI mismatch. The redirect URI in your code must match the one registered with the provider exactly. Trailing slashes matter. The provider will reject the request if they don't match. This is a runtime error. Check your registration.
Validate the state token. If it doesn't match, drop the request immediately.
Decision matrix
Use the oauth2 crate when you need a robust, type-safe implementation of the standard flows. It handles CSRF, PKCE, and token refresh logic for you. The type state pattern prevents configuration errors at compile time.
Use the oauth2 crate with reqwest when you are building a server or CLI and want a familiar async HTTP backend. The integration is seamless, and the community expects this combination.
Reach for manual HTTP requests only when you are integrating with a proprietary flow that deviates significantly from the RFC and the crate cannot be extended to support it. This is rare. Most providers follow the standard closely enough that the crate works.
Pick oauth2 over rolling your own state management. The crate enforces the protocol rules. You'll save time and avoid security bugs.
Trust the crate. It has seen more edge cases than you have.