Cookbook / Public APIs

API keys with scopes

Issue keys that are stored only as hashes, authenticate them on every request, and limit what each key may do.

For a public API, customers want long-lived keys they can create, scope and revoke. generate_key makes a key with a 256-bit secret. You show it to its owner once and store only a hash. ApiKeyAuth authenticates each request in constant time and attaches the key's scopes, and require_scope guards routes.

src/main.rs
use axeon::auth::api_key::{generate_key, ApiKey, ApiKeyAuth, ApiKeyRecord, KeyHash};
use axeon::auth::require_scope;
use axeon::{json, Json, Server, ServerError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

/// Whoever owns a key, attached to authenticated requests.
#[derive(Clone)]
struct Org {
    name: String,
}

/// What you would keep in a database: the key's ID, its hash and its scopes.
#[derive(Clone)]
struct Stored {
    hash: KeyHash,
    scopes: Vec<String>,
    org: Org,
}

type Keys = Arc<Mutex<HashMap<String, Stored>>>;

fn main() {
    let keys: Keys = Arc::default();

    // Create a demo key for an organisation. In a real app this is an admin action.
    let demo = generate_key("acme_live");
    keys.lock().unwrap().insert(
        demo.id.clone(),
        Stored {
            hash: demo.hash,
            scopes: vec!["catalog:read".into()],
            org: Org { name: "Acme".into() },
        },
    );
    println!("demo key (shown once): {}", demo.key);

    let lookup_keys = keys.clone();
    let mut app = Server::new();
    app.middleware(ApiKeyAuth::new("acme_live", move |id: String| {
        let keys = lookup_keys.clone();
        async move {
            // Replace with: SELECT hash, scopes, org FROM api_keys WHERE id = $1 AND revoked_at IS NULL
            Ok::<_, ServerError>(keys.lock().unwrap().get(&id).map(|stored| ApiKeyRecord {
                hash: stored.hash,
                scopes: stored.scopes.clone(),
                identity: stored.org.clone(),
            }))
        }
    }));

    // Needs the catalog:read scope; a key without it gets 403.
    app.route("/v1/products")
        .middleware(require_scope("catalog:read"))
        .get(|req| async move {
            let org = req.extension::<Org>().unwrap();
            Json(json!({ "org": org.name, "products": ["lamp", "desk"] }))
        });

    // Needs a scope the demo key does not have.
    app.route("/v1/products")
        .middleware(require_scope("catalog:write"))
        .post(|_req| async { (201, "created") });

    app.get("/v1/whoami", |req| async move {
        req.extension::<ApiKey>().map(|key| Json(json!({ "key_id": key.id, "scopes": key.scopes })))
    });

    app.listen("127.0.0.1:3000").expect("server failed to start");
}
Terminal
# Use the key printed at startup.
curl -i http://127.0.0.1:3000/v1/products                                    # 401
curl http://127.0.0.1:3000/v1/products -H "authorization: Bearer $KEY"      # 200
curl http://127.0.0.1:3000/v1/products -H "x-api-key: $KEY"                 # same, other header
curl -i -X POST http://127.0.0.1:3000/v1/products -H "x-api-key: $KEY"      # 403, insufficient_scope

Notes#

  • A key looks like acme_live_<id>_<secret>. The prefix lets secret scanners recognise a leaked key, and the ID is the public part you look the key up by. Show the full key to its owner exactly once.
  • Store KeyHash::to_hex() in a TEXT column (or as_bytes() in BYTEA). Keys carry 256 random bits, so a fast hash is enough; slow password hashes matter only for secrets people choose.
  • Lookups are cached for 60 seconds (cache_ttl), so a revoked key keeps working for up to that long. Use Duration::ZERO if revocation must be immediate.
  • Unknown key IDs are never cached and each one reaches your lookup function. Put a rate limit by IP in front of this middleware.
  • on_used(interval, flush) reports the IDs that were used, in batches, for a last_used_at column, instead of one write per request.
  • Scopes support wildcards: a granted catalog:* covers catalog:read, and * covers everything. Roles work the same way with require_role. For checks that depend on the request, call req.require_scope("..") inside the handler.