Back to sh0
sh0

Auth in Rust: Argon2id, JWT, TOTP, and API Keys

Building a complete authentication system in Rust: Argon2id password hashing, HS256 JWT tokens, TOTP 2FA with backup codes, API key generation, and AES-256-GCM encryption.

Thales & Claude | March 25, 2026 12 min sh0
authrustargon2idjwttotpapi-keyssecurityencryption

Most PaaS platforms outsource authentication to a third-party service. Auth0, Clerk, Supabase Auth -- there is no shortage of options. But sh0.dev ships as a single binary that you run on your own server with zero external dependencies. There is no "call out to a hosted auth provider" when you are the entire platform. We had to build every layer ourselves: password hashing, token issuance, two-factor authentication, API key management, and a master encryption system to protect secrets at rest.

This article walks through the five authentication layers we built in Phase 9, the design decisions behind each one, and the code that ties them together.

---

The sh0-auth Crate

Authentication lives in its own crate -- sh0-auth -- separate from the API handlers, the database layer, and the main binary. This separation is deliberate. Auth logic changes rarely. It needs its own test suite. And isolating cryptographic code into a focused module makes auditing tractable.

The crate exposes five modules: password, jwt, api_key, crypto, and totp. Each one owns a single responsibility. The API layer (sh0-api) calls into sh0-auth through clean function boundaries; it never touches raw cryptographic primitives directly.

---

Layer 1: Argon2id Password Hashing

We chose Argon2id -- the winner of the 2015 Password Hashing Competition -- for password storage. It combines Argon2i's resistance to side-channel attacks with Argon2d's resistance to GPU cracking. The implementation uses the argon2 crate with PHC string format output, which encodes the algorithm, version, memory cost, time cost, parallelism, salt, and hash into a single self-describing string.

use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};

pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); // Argon2id v19, 19MB memory, 2 iterations let hash = argon2 .hash_password(password.as_bytes(), &salt) .map_err(|_| AuthError::HashFailed)?; Ok(hash.to_string()) }

pub fn verify_password(password: &str, hash: &str) -> Result { let parsed = PasswordHash::new(hash).map_err(|_| AuthError::InvalidHash)?; Ok(Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok()) } ```

The PHC format matters because it makes the hash self-upgrading. If we later increase memory cost from 19 MiB to 64 MiB, existing hashes still verify correctly -- the parameters are embedded in the string. New passwords get the stronger parameters. No migration needed.

One subtle point: during the security audit (more on that in a later article), we discovered a user enumeration timing vulnerability. When a login attempt targeted a non-existent user, the server returned immediately -- no hash computation. An attacker could distinguish "user not found" from "wrong password" by measuring response time. The fix: always run a dummy Argon2id verification even when the user does not exist.

---

Layer 2: JWT Tokens (HS256, 7-Day Expiry)

Once a user authenticates with their password, sh0 issues a JSON Web Token. We use HS256 (HMAC-SHA256) symmetric signing via the jsonwebtoken crate. Asymmetric algorithms like RS256 make sense when multiple services need to verify tokens independently. sh0 is a single binary -- there is no service mesh, no microservice boundary. HS256 is simpler, faster, and perfectly appropriate.

use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: String, // user ID pub email: String, pub role: String, pub exp: usize, // expiry (Unix timestamp) pub iat: usize, // issued at pub jti: String, // unique token ID (UUID v4) }

pub fn create_token(user_id: &str, email: &str, role: &str, secret: &[u8]) -> Result { let now = chrono::Utc::now().timestamp() as usize; let claims = Claims { sub: user_id.to_string(), email: email.to_string(), role: role.to_string(), exp: now + 7 24 60 * 60, // 7 days iat: now, jti: Uuid::new_v4().to_string(), }; encode(&Header::default(), &claims, &EncodingKey::from_secret(secret)) .map_err(|_| AuthError::TokenCreationFailed) } ```

Every token gets a UUID v4 jti (JWT ID) claim. This serves two purposes: it makes every token unique even if issued in the same second for the same user, and it provides a handle for future token revocation if we add a blocklist.

The JWT secret itself is auto-generated on first run -- 64 bytes from a cryptographic RNG, written to a file at the path specified by --jwt-secret-path. On subsequent starts, the binary loads the existing secret. This means tokens survive server restarts but are unique to each installation.

---

Layer 3: API Keys with Prefix Lookup

Tokens are fine for browser sessions, but CI/CD pipelines and CLI tools need something persistent. That is what API keys are for. Our key format: sh0_ prefix followed by 32 cryptographically random alphanumeric characters.

The design borrows from Stripe's approach: we never store the full key. Instead, we store a SHA-256 hash of the key and a separate key_prefix column containing the first 8 characters after sh0_. When a request arrives with an API key, we extract the prefix, look up candidate keys by prefix (a fast indexed query), then verify the full key against the stored hash using constant-time comparison.

use ring::rand::{SecureRandom, SystemRandom};
use ring::digest::{digest, SHA256};
use subtle::ConstantTimeEq;

const KEY_PREFIX: &str = "sh0_"; const KEY_LENGTH: usize = 32;

pub fn generate_api_key() -> (String, String, String) { let rng = SystemRandom::new(); let mut key_bytes = [0u8; KEY_LENGTH]; rng.fill(&mut key_bytes).expect("RNG failure");

let key_chars: String = key_bytes.iter() .map(|b| { let charset = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; charset[(*b as usize) % charset.len()] as char }) .collect();

let full_key = format!("{}{}", KEY_PREFIX, key_chars); let prefix = key_chars[..8].to_string(); let hash = hex::encode(digest(&SHA256, full_key.as_bytes()).as_ref());

(full_key, prefix, hash) // full_key shown once; prefix + hash stored in DB } ```

The constant-time comparison deserves emphasis. Our initial implementation used == to compare hashes -- a classic timing attack vulnerability. If the first byte mismatches, == returns immediately; if the first 31 bytes match, it takes measurably longer. An attacker could reconstruct the hash byte by byte. The subtle crate's ConstantTimeEq trait eliminates this by always comparing every byte regardless of match position.

The key_prefix column has a database index. When a request arrives bearing sh0_aBcDeFgH..., we query WHERE key_prefix = 'aBcDeFgH' -- typically returning exactly one row -- then verify the full SHA-256 hash. This avoids scanning every key in the table on every authenticated request.

---

Layer 4: AES-256-GCM Master Encryption

A PaaS stores secrets: database passwords, API keys for third-party services, environment variables marked as sensitive. These cannot live in plaintext in the database. sh0 uses AES-256-GCM (Galois/Counter Mode) for envelope encryption, with keys derived from a master passphrase via PBKDF2.

use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
use ring::pbkdf2;
use ring::rand::{SecureRandom, SystemRandom};

const PBKDF2_ITERATIONS: u32 = 100_000;

pub fn derive_key(passphrase: &str, salt: &[u8]) -> LessSafeKey { let mut key_bytes = [0u8; 32]; pbkdf2::derive( pbkdf2::PBKDF2_HMAC_SHA256, std::num::NonZeroU32::new(PBKDF2_ITERATIONS).unwrap(), salt, passphrase.as_bytes(), &mut key_bytes, ); let unbound = UnboundKey::new(&AES_256_GCM, &key_bytes).unwrap(); LessSafeKey::new(unbound) } ```

The architecture works like this: on first run, if the SH0_MASTER_PASSPHRASE environment variable is set, sh0 derives a master key using PBKDF2 with 100,000 iterations and a random salt. The salt (but not the key) is stored alongside the encrypted data. Each encryption operation generates a fresh 96-bit nonce. The nonce and ciphertext are concatenated for storage; decryption splits them apart.

GCM mode provides both confidentiality and authenticity -- if an attacker modifies the ciphertext, decryption fails rather than producing corrupted plaintext. This matters when the encrypted values are database credentials that will be injected into running containers.

The master key is loaded into AppState at startup and available to any handler that needs to encrypt or decrypt. It never leaves server memory, it is never logged, and it is never included in API responses.

---

Layer 5: TOTP Two-Factor Authentication

The final layer is time-based one-time passwords (RFC 6238). We implemented this with the totp-rs crate, generating 6-digit codes with a 30-second window and +/-1 step tolerance (meaning the code from the previous and next 30-second window are also accepted, accounting for clock skew).

The TOTP setup flow has three stages:

1. Setup: the server generates a random base32 secret and returns it along with an otpauth:// URI (for QR code scanning) and a setup_nonce. 2. Confirm: the user submits the setup_nonce plus a valid TOTP code from their authenticator app. This proves they successfully registered the secret. The server stores the secret and generates 10 backup codes. 3. Login with 2FA: after password verification succeeds, the server returns a totp_required: true flag. The client must then submit the TOTP code in a second request.

Backup codes are single-use recovery codes, hashed with Argon2id before storage (the same hashing we use for passwords). When a backup code is consumed during login, it is permanently deleted from the database. This was not in the original plan -- the first implementation generated backup codes in the API response but never persisted them. The security audit caught this as a critical finding.

The setup_nonce in the confirm step deserves explanation. Without it, an attacker who intercepts the setup response could confirm TOTP on the victim's account using their own authenticator app. The nonce binds the confirm request to the specific setup session.

---

The AuthUser Extractor

All five layers converge in one place: the Axum AuthUser extractor. This is where authentication actually happens on every request.

#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
    AppState: FromRef<S>,
{
    type Rejection = ApiError;

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app_state = AppState::from_ref(state);

// Priority 1: Bearer token in Authorization header if let Some(token) = extract_bearer_token(&parts.headers) { return verify_jwt(token, &app_state.jwt_secret).await; }

// Priority 2: sh0_access HTTP-only cookie if let Some(token) = extract_cookie(&parts.headers, "sh0_access") { return verify_jwt(token, &app_state.jwt_secret).await; }

// Priority 3: API key (sh0_ prefix) if let Some(key) = extract_bearer_token(&parts.headers) { if key.starts_with("sh0_") { return verify_api_key(key, &app_state.db).await; } }

Err(ApiError::Unauthorized) } } ```

Any handler that needs authentication simply adds auth: AuthUser to its parameter list. Axum calls the extractor before the handler runs. If extraction fails, the handler never executes -- the client gets a 401. This pattern means authentication logic is written once and enforced everywhere by the type system. Forgetting to add AuthUser to a handler is the only way to create an unauthenticated endpoint, and code review catches that.

We also built an OptionalAuth extractor for endpoints that behave differently for authenticated and anonymous users (like health checks that show extra detail to admins).

---

The First-Install Setup Flow

sh0 has no default admin account. The first time you start the binary, the system is in "setup mode." The /api/auth/setup endpoint is only available when zero users exist in the database. It accepts an email and password, creates the admin user, hashes the password with Argon2id, issues a JWT, and transitions the system to normal mode. After that, the setup endpoint returns 400 ("Setup already done").

This eliminates a common security problem with self-hosted tools: default credentials. There is no admin/admin to forget to change. The first person to access the fresh installation becomes the admin.

On the CLI side, the main binary auto-generates the JWT secret file on first run using 64 bytes from SystemRandom. It also handles master key derivation from the SH0_MASTER_PASSPHRASE environment variable. The goal: ./sh0 should work on the first invocation with zero configuration, generating all cryptographic material automatically.

---

24 Tests for Cryptographic Code

Cryptographic code that is not tested is broken code you have not discovered yet. We wrote 24 unit tests in sh0-auth covering:

  • Password hashing produces different hashes for the same input (random salts)
  • Password verification succeeds for correct passwords and fails for incorrect ones
  • JWT creation and verification round-trip correctly
  • Expired JWTs are rejected
  • API key generation produces the correct prefix format
  • API key hash comparison works with constant-time equality
  • AES-256-GCM encryption/decryption round-trips
  • Tampered ciphertext is rejected
  • TOTP code generation and verification with clock skew tolerance
  • Backup code hashing and verification

On top of those, 5 integration tests in sh0-api exercise the full flow: setup, login, profile retrieval, 401 rejection without a token, and API key authentication. Every existing integration test was updated to authenticate through the setup flow first.

Total test count after Phase 9: 162, all passing.

---

What We Would Do Differently

The initial 7-day JWT expiry was too long. The security audit flagged it as a medium finding -- the recommendation was 15-30 minutes with refresh tokens. We later implemented that in the HTTP-only cookie migration (Article 11 in this series). Short-lived access tokens with a 30-day refresh token stored in an HTTP-only cookie is the correct architecture. The 7-day expiry was a "get it working" decision that we knew we would revisit.

We also initially used CorsLayer::permissive(), which allows all origins. Acceptable for local development, unacceptable for production. The audit caught it, and we replaced it with a configurable origin list via the SH0_CORS_ORIGINS environment variable.

---

Key Takeaways

1. Isolate auth in its own crate. Cryptographic code benefits from tight scope and independent testing. 2. Use the type system for enforcement. The AuthUser extractor makes unauthenticated access a compile-time concern, not a runtime one. 3. Never store raw secrets. Passwords get Argon2id. API keys get SHA-256. Environment variables get AES-256-GCM. TOTP secrets and backup codes get hashed. 4. Auto-generate everything on first run. No default credentials, no manual key generation, no configuration files to create before the binary works. 5. Constant-time comparison is not optional. Timing attacks on API keys are real and exploitable.

---

Next in the series: We Audited Our Own Platform and Found 88 Security Issues -- what happens when you turn the security audit on yourself before anyone else does.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles