verticallines

Loading...

Accounts Microservice

Rahul Hathwar - 2025-01-01 - Building a High-Performance Authentication Service with Rust and ScyllaDB

Project Overview

The Accounts microservice is the authentication and user management backbone of the RoConnection Platform, a Roblox-focused networking service. While user authentication might seem straightforward on the surface, this implementation demonstrates that production-grade systems require careful consideration of scalability, security, and data modeling challenges.

Built entirely in Rust using the Rocket web framework, this service handles user registration, authentication, profile management, and portfolio features (work experiences and skills) for Roblox developers seeking to connect and collaborate.

Core Tech Stack:

  • Rust (Language) with Rocket 0.5 (Web Framework)
  • ScyllaDB 0.4. 7 (Primary Database)
  • Redis (Verification Codes and Session Storage)
  • bcrypt (Password Hashing)
  • Google Authenticator (TOTP-based 2FA)

The Problem Space

Building an accounts service for a Roblox-centric platform introduces unique challenges:

  1. Roblox-First Identity: Users authenticate using their Roblox User IDs, requiring a verification flow to prove ownership
  2. Dual Access Patterns: Data must be queryable both by internal account IDs (UUIDs) and by Roblox User IDs
  3. Portfolio Data: Beyond basic authentication, users need to showcase their development experience and skills
  4. Security at Scale: Password hashing, 2FA, and session management must be both secure and performant

Architecture & Data Modeling

The Dual-Table Strategy

One of the most interesting design decisions was how to handle ScyllaDB's data modeling constraints. ScyllaDB (like Cassandra) requires you to design tables around your query patterns. The solution? Maintain two synchronized tables:

// Query pattern 1: Lookup by Account ID
INSERT INTO ACCOUNTS.ACCOUNTS_BY_ID (
    ACCOUNT_ID,      // UUID v1 (primary key)
    RBLX_ID,         // Roblox User ID
    EMAIL, PASSWORD,
    IS_SUSPENDED, IS_VERIFIED,
    AUTHENTICATOR, AUTHENTICATOR_SECRET,
    // ... profile fields
) VALUES (?, ?, ?, ... );

// Query pattern 2: Lookup by Roblox User ID  
INSERT INTO ACCOUNTS.ACCOUNTS_BY_RBLX_ID (
    RBLX_ID,         // Roblox User ID (primary key)
    ACCOUNT_ID,      // UUID v1
    // ... same fields
) VALUES (?, ?, ?, ...);

This denormalization is a classic NoSQL pattern. Every write operation updates both tables, but reads become simple, fast primary key lookups. The trade-off is write amplification for read performance, which is appropriate for an authentication service where reads vastly outnumber writes.

Custom ScyllaDB Integration

Rocket's database pooling system is extensible but doesn't natively support ScyllaDB. The solution was implementing a custom pool adapter:

pub struct ScyllaSession(SessionConfig);

#[rocket::async_trait]
impl Pool for ScyllaSession {
    type Error = Error<NewSessionError>;
    type Connection = Session;

    async fn init(figment: &Figment) -> Result<Self, Self::Error> {
        let config = figment.extract::<Config>()?;
        let mut session_config = SessionConfig::new();
        session_config.known_nodes. push(KnownNode::Hostname(config.url));
        Ok(ScyllaSession(session_config))
    }

    async fn get(&self) -> Result<Self::Connection, Self::Error> {
        Session::connect(self.0.clone()). await
            .map_err(|e| Error::Get(e))
    }
}

This adapter bridges Rocket's database pool expectations with ScyllaDB's session-based connection model, allowing idiomatic Rocket code throughout the application.


Authentication Flow

The Roblox Verification Challenge

Since users authenticate with Roblox User IDs rather than traditional usernames, proving ownership is critical. The verification flow works as follows:

  1. Code Generation: When a user attempts to sign up or login, the service generates a random verification code and stores it in Redis with the user's Roblox ID as the key:
pub async fn generate_verification_code(
    roblox_userid: RobloxID,
    conn: &mut Connection<VerificationCodes>,
) -> Result<String, CommonServiceError> {
    let verification_code = utils::generate_verification_code(16);
    
    // Store with 1-hour expiration
    redis::cmd("SET")
        . arg(&[format!("{}", roblox_userid. into_inner())])
        .arg(&[verification_code. as_str()])
        .query_async(&mut **conn)
        .await?;
    
    redis::cmd("EXPIRE")
        .arg(&[format!("{}", roblox_userid.into_inner())])
        .arg(&[format!("{}", 3600000)])  // 1 hour
        . query_async(&mut **conn)
        .await?;
    
    Ok(verification_code)
}
  1. Out-of-Band Verification: The user must display this code in their Roblox profile or game to prove they control that account
  2. Code Validation: On signup/login, the service verifies the submitted code matches the Redis-stored value

This Redis-based approach provides automatic expiration and high-performance lookups without cluttering the primary database.

Multi-Factor Authentication with TOTP

Security-conscious users can enable TOTP-based 2FA. The implementation uses the google-authenticator crate:

pub async fn enable_2fa(
    roblox_userid: RobloxID,
    conn_accounts: &Connection<Accounts>,
    google_auth: &State<GoogleAuthenticator>
) -> Result<String, CommonServiceError> {
    // Generate a 32-character secret
    let secret = google_auth.create_secret(32);
    
    let account_id = get_account_id(roblox_userid, conn_accounts).await?;
    
    // Update both tables with authenticator enabled and secret stored
    conn_accounts. query(
        UPDATE_AUTHENTICATOR_SECRET_IN_ACCOUNTS_BY_ID,
        (true, &secret, account_id, &roblox_userid.into_inner())
    ).await?;
    
    conn_accounts. query(
        UPDATE_AUTHENTICATOR_SECRET_IN_ACCOUNTS_BY_RBLX_ID,
        (true, &secret, &roblox_userid.into_inner(), account_id)
    ).await?;
    
    // Return QR code as SVG for scanning into authenticator app
    Ok(google_auth.qr_code(
        &secret, "qr_code", "name", 200, 200,
        google_authenticator::ErrorCorrectionLevel::High
    )?)
}

During login, if 2FA is enabled, the authentication flow becomes:

if account. authenticator {
    if let Some(authentication_code) = &input.authentication_code {
        if let Some(authenticator_secret) = account.authenticator_secret {
            let code = google_auth.get_code(&authenticator_secret, 0)? ;
            
            if authentication_code. to_string() == code {
                return Ok(LoginResponses::Success(()));
            } else {
                return Err(status::BadRequest(
                    Some("Invalid authentication code".to_string())
                ));
            }
        }
    } else {
        // Redirect to 2FA input page
        return Ok(LoginResponses::Redirect(Redirect::to("/login/2fa")))
    }
}

Password Security with bcrypt

Password hashing uses bcrypt with Rust's type-safe implementation:

pub async fn update_password(
    roblox_userid: RobloxID,
    password: &str,
    conn_accounts: &Connection<Accounts>,
) -> Result<(), CommonServiceError> {
    let account_id = get_account_id(roblox_userid, conn_accounts).await?;
    
    // bcrypt with default cost factor (12 rounds)
    let hashed_password = hash(password, DEFAULT_COST)?;
    
    // Update both tables
    conn_accounts. query(
        UPDATE_PASSWORD_IN_ACCOUNTS_BY_ID,
        (&hashed_password, &account_id, &roblox_userid.into_inner())
    ).await?;
    
    conn_accounts.query(
        UPDATE_PASSWORD_IN_ACCOUNTS_BY_RBLX_ID,
        (&hashed_password, &roblox_userid.into_inner(), &account_id)
    ).await?;
    
    Ok(())
}

Verification during login uses bcrypt's constant-time comparison:

if let Some(db_password) = &account.password {
    if let Some(password) = &input.password {
        if ! verify(password, db_password)? {
            return Err(CommonServiceError::InvalidAuthenticationInformation);
        }
    }
}

Account Registration & UUID Generation

New accounts receive UUID v1 identifiers, which embed timestamp information for chronological sorting:

pub async fn create_account(
    roblox_userid: RobloxID,
    uuid_context: &State<UuidContext>,
    conn: &Connection<Accounts>
) -> Result<Uuid, CommonServiceError> {
    // Generate UUID v1 with current timestamp
    let current_time = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap();
    
    let timestamp = Timestamp::from_unix(
        &uuid_context.context,
        current_time.as_secs(),
        current_time.subsec_nanos()
    );
    
    let account_id: Uuid = Uuid::new_v1(timestamp, &[1, 2, 3, 4, 5, 6]);
    
    // Create account model with sensible defaults
    let account = types::data_models::Account {
        account_id,
        roblox_userid,
        email: None,
        password: None,
        is_suspended: false,
        is_verified: false,
        last_active: Duration::from_chrono(chrono::Duration::zero())?,
        authenticator: false,
        authenticator_secret: None,
        timezone: None,
        theme: None,
        language: None,
        country_code: None,
        tagline: None,
        biography: None,
        website: None,
        public_email: None,
        twitter: None,
        instagram: None,
        facebook: None,
        youtube: None,
        github: None,
    };
    
    // Prepare statements for both tables
    let prepared_1 = conn.prepare(INSERT_INTO_ACCOUNTS_BY_ID). await?;
    let prepared_2 = conn.prepare(INSERT_INTO_ACCOUNTS_BY_RBLX_ID).await?;
    
    // Execute both inserts
    conn.execute(&prepared_1, &account).await?;
    conn. execute(&prepared_2, &account).await?;
    
    Ok(account_id)
}

Using prepared statements provides two benefits:

  1. Performance: ScyllaDB compiles the query once and caches it
  2. Security: Automatic parameter binding prevents injection attacks

Profile Management

Beyond authentication, users build developer portfolios with work experiences and skills.

Experience Tracking

Work experience entries (title, company, dates, description) are stored in a separate table partitioned by account ID:

pub async fn create_experience(
    roblox_userid: RobloxID,
    input: &Form<Experience>,
    conn_accounts: &Connection<Accounts>,
) -> Result<(), CommonServiceError> {
    // Check for duplicate titles
    if let Ok(_) = get_experience(roblox_userid, &input.title, conn_accounts). await {
        return Err(CommonServiceError::ExperienceAlreadyExists);
    }
    
    let experience = Experience {
        account_id: input.account_id,
        title: input.title.clone(),
        company: input.company.clone(),
        start_date: input.start_date.clone(),
        end_date: input.end_date.clone(),
        description: input.description.clone(),
    };
    
    let prepared = conn_accounts.prepare(
        "INSERT INTO ACCOUNTS.EXPERIENCES_BY_ACCOUNT_ID 
         (account_id, title, company, start_date, end_date, description) 
         VALUES (?, ?, ?, ?, ?, ? )"
    ).await?;
    
    conn_accounts.execute(&prepared, &experience).await?;
    
    Ok(())
}

Skills with Collection Types

ScyllaDB's collection types enable elegant skill management. Skills are grouped into categories, with the skills field as a list:

pub async fn add_skill(
    roblox_userid: RobloxID,
    skill_category: &str,
    skill: &str,
    conn_accounts: &Connection<Accounts>,
) -> Result<(), CommonServiceError> {
    let account_id = get_account_id(roblox_userid, conn_accounts).await?;
    
    // Use ScyllaDB's collection append operation
    let prepared = conn_accounts.prepare(
        "UPDATE ACCOUNTS.SKILLS_CATEGORY_BY_ACCOUNT_ID 
         SET skills = skills + ? 
         WHERE account_id = ?  AND name = ?"
    ).await? ;
    
    conn_accounts.execute(
        &prepared,
        (vec![skill.to_string()], account_id, skill_category. to_string())
    ).await?;
    
    Ok(())
}

The skills = skills + ? syntax appends to the collection atomically, and removal uses skills = skills - ? for deletion.


Error Handling & Type Safety

Rust's type system prevents entire classes of bugs. Custom error types use the thiserror crate for ergonomic error handling:

#[derive(Error, Debug)]
pub enum CommonServiceError {
    #[error("Error with redis: {0}. ")]
    Redis(#[from] redis::RedisError),
    
    #[error("Error with scylla: {0}.")]
    Scylla(#[from] scylla::transport::errors::QueryError),
    
    #[error("Error with bcrypt: {0}.")]
    Bcrypt(#[from] bcrypt::BcryptError),
    
    #[error("Account does not exist.")]
    AccountDoesNotExist,
    
    #[error("Invalid authentication information.")]
    InvalidAuthenticationInformation,
    
    #[error("Account is suspended.")]
    AccountSuspended,
    
    // ... more variants
}

The #[from] attribute automatically converts underlying library errors into the service's error type, allowing the ? operator to propagate errors cleanly throughout the codebase.

Custom wrapper types provide additional type safety:

pub struct RobloxID(i64);

impl RobloxID {
    pub fn into_inner(self) -> i64 {
        self.0
    }
}

This prevents accidentally passing raw integers where Roblox IDs are expected, catching bugs at compile time.


Granular Account Updates

The update system handles 14 different profile fields through a unified interface. Each field update requires authentication and updates both database tables:

#[post("/update-account/<roblox_userid>", data = "<input>")]
pub async fn update_account(
    roblox_userid: RobloxID,
    input: Form<UpdateAccountForm>,
    mut conn_codes: Connection<VerificationCodes>,
    conn_accounts: Connection<Accounts>,
) -> Result<(), status::BadRequest<String>> {
    // Ensure at least one field is being updated
    if ! input.any_modified_fields() {
        return Err(status::BadRequest(Some(
            "No account modifications made. ".to_string()
        )));
    }
    
    // Authenticate before allowing updates
    let login_form: rocket::form::Form<LoginForm> = LoginForm {
        roblox_userid: roblox_userid.clone(),
        verification_code: input.verification_code.clone(),
        password: input.password.clone(),
        authentication_code: input.authentication_code.clone(),
    }. into();
    
    services::login::authenticate_account(&login_form, &conn_accounts, &mut conn_codes)
        .await
        .map_err(|e| status::BadRequest(Some(e.to_string())))?;
    
    // Update each provided field
    if let Some(email) = &input.email {
        services::update::update_email(roblox_userid, email, &conn_accounts)
            .await
            .map_err(|e| status::BadRequest(Some(format!("Error updating email: {}", e))))?;
    }
    
    if let Some(password) = &input.new_password {
        services::update::update_password(roblox_userid, password, &conn_accounts)
            .await
            .map_err(|e| status::BadRequest(Some(format!("Error updating password: {}", e))))?;
    }
    
    // ...  similar handling for other fields
    
    Ok(())
}

This design prioritizes security (re-authentication required) and atomicity (each field update is independent).


Deployment & Containerization

The Dockerfile uses multi-stage builds to minimize image size and attack surface:

# Stage 1: Build
FROM rust:1.62. 0 as build

RUN USER=root cargo new --bin accounts
WORKDIR /accounts

# Cache dependencies separately from source
COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml
RUN cargo build --release
RUN rm src/*.rs

# Build actual application
COPY ./src ./src
RUN rm ./target/release/deps/accounts*
RUN cargo build --release

# Stage 2: Runtime
FROM gcr.io/distroless/cc-debian11

COPY --from=build /accounts/target/release/accounts /usr/src/accounts

ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=3000
ENV ROCKET_LOG_LEVEL=debug

EXPOSE 3000

CMD ["/usr/src/accounts"]

Key decisions:

  • Dependency caching: Copying Cargo.toml first leverages Docker layer caching, so dependencies only rebuild when they change
  • Distroless base: The final image uses Google's distroless base (only 20MB overhead) containing just the C runtime, no shell or package manager, dramatically reducing attack surface
  • Single binary: Rust compiles to a static binary with no runtime dependencies

Design Decisions & Trade-offs

Why ScyllaDB Over PostgreSQL?

The original article claimed PostgreSQL as an "optional secondary store," but the actual implementation uses ScyllaDB exclusively for account data. This choice reflects specific requirements:

Pros:

  • Write scalability: Wide-column stores handle high write throughput better than traditional RDBMS
  • Horizontal scaling: Add nodes to increase capacity linearly
  • Low latency at scale: Predictable sub-millisecond reads/writes even as data grows

Cons:

  • No joins: Data modeling requires denormalization (hence the dual-table approach)
  • Eventual consistency: Default consistency model requires careful tuning for strong consistency when needed
  • Limited query flexibility: Must know access patterns upfront

For an authentication service with known query patterns (lookup by ID or Roblox ID), this trade-off favors ScyllaDB.

Why Rust?

Rust provides memory safety without garbage collection, making it ideal for services where consistent latency matters:

  • Zero-cost abstractions: High-level constructs compile to machine code as efficient as hand-written C
  • Fearless concurrency: The type system prevents data races at compile time
  • No GC pauses: Deterministic performance without unpredictable garbage collection

The learning curve is steep, but the result is a service that's both safe and fast.

Authentication Before Updates

Requiring full authentication (including verification codes and potential 2FA) before profile updates might seem excessive, but it prevents session hijacking attacks where an attacker with a stolen session cookie modifies critical account settings.


What's Not Implemented (Yet)

Looking at the code reveals some TODO comments and incomplete features:

  1. Session invalidation: The 2FA enable/disable functions note "TODO: Remove the account session" but don't implement it yet
  2. Kafka integration: Despite the original article mentioning Kafka extensively, the codebase doesn't contain any event publishing code
  3. Rate limiting: No rate limiting appears in the implementation, leaving endpoints vulnerable to brute force attacks
  4. Email verification: While the data model has an is_verified field, email verification flows aren't implemented
  5. Integration tests: The test module contains only comments outlining test cases, no actual tests

These represent realistic next steps for production readiness.


Real-World Performance Considerations

Without benchmarks in the repository, it's premature to claim "50,000 requests/second" or "sub-millisecond latency." Real-world performance depends on:

  1. Database configuration: ScyllaDB's performance is sensitive to cluster size, replication factor, and consistency levels
  2. Redis configuration: Single Redis instance vs. Redis Cluster dramatically affects throughput
  3. Connection pooling: The current implementation creates a new ScyllaDB session per request, which is expensive (sessions should be pooled)
  4. bcrypt cost factor: The default cost of 12 takes ~100ms per hash, limiting login throughput to ~10 req/sec per CPU core

Performance optimization would likely focus on connection pooling and caching strategies first.


Conclusion

This accounts microservice demonstrates practical implementation of several important patterns:

  • Custom database adapters for integrating unsupported databases with web frameworks
  • Dual-table denormalization to satisfy multiple access patterns in wide-column stores
  • Type-safe error handling using Rust's powerful enum and trait systems
  • Security layering with bcrypt, TOTP 2FA, and verification flows
  • Container optimization with multi-stage Docker builds and distroless bases

The codebase reflects a pragmatic approach: it handles the complex parts (authentication security, database duality) thoroughly while leaving some features (rate limiting, full event streaming) for future iterations. This is realistic engineering for a project in active development.

For developers evaluating this work, the most impressive aspects are the custom ScyllaDB integration, the thoughtful dual-table data modeling, and the end-to-end type safety that Rust provides throughout the authentication flow.

Copyright © Rahul Hathwar. All Rights Reserved.