Back to flin
flin

#139 -- Admin Login and Authentication

How FLIN admin console handles authentication with session-based login and role-based access control.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 7 min flin
EN/ FR/ ES
flinadminauthenticationloginsecurity

The first version of the FLIN admin console had a security model that would make any security auditor wince: the username was "flin" and the password was "flin." Hardcoded. In the source code. Identical for every FLIN application on the planet.

This was intentional -- Session 261 needed to get the console functional for alpha testing, and authentication was a means to an end, not the end itself. But between "functional alpha" and "production deployment," there lies an ocean of security work. Session 319b crossed that ocean, replacing the default credentials with bcrypt-hashed passwords, cryptographically random session tokens, a first-time setup wizard, and email-based two-factor authentication.

Phase 1: The MVP Authentication (Session 261)

The initial login system was deliberately minimal. The goal was to prevent accidental access to the console, not to withstand a determined attacker:

rust// The original authentication -- Session 261
const DEFAULT_USERNAME: &str = "flin";
const DEFAULT_PASSWORD: &str = "flin";
const SESSION_COOKIE: &str = "flin_console_session";

pub fn handle_login(body: &[u8]) -> Response {
    let request: LoginRequest = serde_json::from_slice(body)?;

    if request.username == DEFAULT_USERNAME
        && request.password == DEFAULT_PASSWORD
    {
        let session_id = generate_session_id();
        store_session(&session_id);

        let cookie = format!(
            "{}={}; Path=/_flin; HttpOnly; SameSite=Strict",
            SESSION_COOKIE, session_id
        );

        json_response_with_cookie(200, &json!({"success": true}), &cookie)
    } else {
        json_response(401, &json!({"error": "Invalid credentials"}))
    }
}

The login page showed the default credentials as a hint, which is unusual for a login screen but appropriate for an alpha tool. The session was stored in memory -- meaning it would not survive a server restart. This was acceptable because the console was only used during development.

The authentication check was applied to all console routes except the login page itself, static assets (CSS, JS), and the setup status endpoint:

rustfn is_public_route(path: &str) -> bool {
    matches!(path,
        "/_flin/login" |
        "/_flin/login.html" |
        "/_flin/api/login" |
        "/_flin/api/setup/status" |
        "/_flin/console.css" |
        "/_flin/console.js" |
        "/_flin/icon.png"
    )
}

Phase 2: Production-Grade Authentication (Session 319b)

The transition from "flin/flin" to production-grade authentication touched every layer of the system.

Bcrypt-Hashed Passwords

The admin account is now stored in .flindb/console_admin.json with a bcrypt-hashed password. Bcrypt was chosen over SHA-256 or Argon2 for pragmatic reasons: the bcrypt Rust crate is mature, the algorithm is well-understood, and the computational cost (default cost factor 12) makes brute-force attacks impractical.

rustuse bcrypt::{hash, verify, DEFAULT_COST};

pub fn create_admin(email: &str, password: &str) -> Result<(), AdminError> {
    let hashed = hash(password, DEFAULT_COST)?;

    let admin = ConsoleAdmin {
        email: email.to_string(),
        password_hash: hashed,
        two_factor_enabled: false,
        created_at: Utc::now(),
    };

    let json = serde_json::to_string_pretty(&admin)?;
    fs::write(".flindb/console_admin.json", json)?;

    Ok(())
}

pub fn verify_password(password: &str, hash: &str) -> bool {
    verify(password, hash).unwrap_or(false)
}

The admin file lives inside .flindb/, the same directory as the database. This directory should be in .gitignore (FLIN's project scaffolding adds it automatically), ensuring credentials are never committed to version control.

Cryptographic Session Tokens

Session tokens are 64-character hexadecimal strings generated from a cryptographically secure random number generator:

rustuse rand::Rng;

pub fn generate_session_token() -> String {
    let mut rng = rand::thread_rng();
    let bytes: [u8; 32] = rng.gen();
    hex::encode(bytes)
}

Tokens are stored in an in-memory LazyLock<Mutex<HashMap<String, Instant>>> with 24-hour expiry. The in-memory approach is deliberate for a single-admin console: there is no need for Redis or a session database when only one person can be logged in.

ruststatic SESSIONS: LazyLock<Mutex<HashMap<String, Instant>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));

pub fn is_valid_session(token: &str) -> bool {
    let sessions = SESSIONS.lock().unwrap();
    if let Some(created_at) = sessions.get(token) {
        created_at.elapsed() < Duration::from_secs(24 * 60 * 60)
    } else {
        false
    }
}

The Setup Wizard

New FLIN applications do not have an admin account. The first time a developer visits /_flin, the system checks for the existence of .flindb/console_admin.json. If it does not exist, the developer is redirected to a three-step setup wizard:

flin// Setup wizard flow
step 1: "Create Admin Account"
    - Email input (required)
    - Password input (minimum 8 characters)
    - Confirm password input

step 2: "Enable Two-Factor Authentication (Optional)"
    - Toggle switch for 2FA
    - If enabled: sends verification code to email
    - Verify code to confirm 2FA works

step 3: "Setup Complete"
    - Success message
    - "Go to Dashboard" button

The setup wizard ensures that no FLIN application in production runs with default credentials. The moment you deploy, the first visit forces you to create a real admin account.

Two-Factor Authentication: Email OTP

The 2FA implementation uses email-based one-time passwords rather than TOTP apps (Google Authenticator) or SMS. The reasoning: TOTP requires the developer to have a specific app installed. SMS is unreliable and expensive in West Africa. Email is universal.

FLIN embeds Postmark SMTP directly in the runtime, so sending the OTP email requires zero configuration from the developer -- no SMTP server setup, no API keys, no third-party email service.

rustpub fn send_otp(email: &str) -> Result<(), OtpError> {
    let code = generate_otp_code();  // 6-digit numeric code
    let expiry = Instant::now() + Duration::from_secs(600);  // 10 minutes

    store_pending_otp(email, &code, expiry);

    send_email(
        email,
        "FLIN Console - Verification Code",
        &format!(
            "Your FLIN admin console verification code is: {}\n\n\
             This code expires in 10 minutes.\n\n\
             If you did not request this code, ignore this email.",
            code
        ),
    )?;

    Ok(())
}

The login flow with 2FA works in two steps:

  1. The developer submits email and password. If credentials are valid and 2FA is enabled, the API returns { "requires_2fa": true, "temp_token": "..." } instead of a session cookie.
  1. The developer enters the 6-digit code from their email. The API verifies the code against the stored OTP and, if valid, issues a full session token.
rustpub fn handle_login(body: &[u8]) -> Response {
    let request: LoginRequest = serde_json::from_slice(body)?;
    let admin = load_admin()?;

    if !verify_password(&request.password, &admin.password_hash) {
        return json_response(401, &json!({"error": "Invalid credentials"}));
    }

    if admin.two_factor_enabled {
        let temp_token = generate_temp_token();
        store_temp_token(&temp_token, &admin.email);
        send_otp(&admin.email)?;

        return json_response(200, &json!({
            "requires_2fa": true,
            "temp_token": temp_token,
        }));
    }

    // No 2FA -- issue session directly
    let session_token = generate_session_token();
    store_session(&session_token);

    json_response_with_cookie(200,
        &json!({"success": true}),
        &format_session_cookie(&session_token),
    )
}

Settings: Password Change and 2FA Management

The Settings page (/_flin/settings) includes a Security section where the admin can:

  • View their email address
  • Change their password (requires current password)
  • Enable or disable 2FA with live verification
  • The 2FA toggle sends a verification code immediately, ensuring the email delivery works before enabling the feature
flin// Security settings API endpoints
route POST "/_flin/api/admin/change-password" {
    guard admin_session
    validate {
        current_password: text @required
        new_password: text @required @min_length(8)
    }

    admin = load_admin()
    if !verify(body.current_password, admin.password_hash) {
        respond 400, { error: "Current password is incorrect" }
    }

    admin.password_hash = hash(body.new_password)
    save_admin(admin)

    respond { success: true }
}

route POST "/_flin/api/admin/2fa/enable" {
    guard admin_session

    admin = load_admin()
    send_otp(admin.email)

    respond { success: true, message: "Verification code sent" }
}

The Evolution of Trust

The authentication system's evolution mirrors a broader pattern in FLIN's development: ship something functional, then harden it. The MVP login let us build and test the rest of the console without blocking on security. The production authentication came when it was needed -- before any external deployment.

This two-phase approach is not cutting corners. It is prioritization. A console with perfect authentication but no entity browser is useless. A console with a working entity browser and "flin/flin" credentials is a powerful development tool. The security layer was always planned; the question was when, not whether.

The authentication system now matches or exceeds what PocketBase and similar tools offer: hashed passwords, session tokens, setup wizards, and 2FA. The difference is that it ships inside the FLIN binary, configured automatically, with no external dependencies.

The next article covers the observability and monitoring features that turn the console from a data browser into a production operations tool.


This is Part 139 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO evolved admin authentication from default credentials to production-grade security.

Series Navigation: - [138] Entity Browser and CRUD Operations - [139] Admin Login and Authentication (you are here) - [140] Observability and Monitoring

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude zerosuite

It Works, and It Is Not Finished

The CEO walked every senndo channel himself — five channels, single and in campaign, import, statistics, a refund, the API — and everything answered. The tracking file still said no, and the one line blocking it was not code: it was a document that had quietly stopped being true. Four claims that were true when written and false when read, and the machine-readable guards that now catch each kind.

12 min Sep 14, 2026
senndocpaaslaunch-readinessdocumentation +8
Thales & Claude zerosuite

The Browser in Claude's Hands: Driving the CEO's Own Chrome

Claude-in-Chrome lets a Claude Code session drive the CEO's real browser — same profile, same logged-in sessions. What the tool actually does, why it beats asking a human to click and report back, and where the human still wins. Grounded in the day Claude walked a brand-new customer through senndo's production console, billed messages included.

8 min Aug 18, 2026
claude-in-chromebrowser-automationclaude-codeclaude-fable-5 +9
Thales & Claude deblo

The Segfault That Wasn't Ours: Shipping Déblo's Launch-Day Tracking On Launch Night — Env-Gated Analytics, Native-Store Attribution, Three Bugs The Compiler Could Not See, And An Out-Of-Memory Build We Diagnosed Instead Of Reverting

On July 1, 2026 — launch day — the risk was never the copy. It was the paid campaigns going out blind. This is the build-log of shipping Déblo's analytics and install attribution as code on launch night: env-gated GA4, Meta, and LinkedIn tags that deploy safe before the ad accounts exist; attribution routed through the stores' native channels instead of the web pixel; an adversarial audit that caught three bugs the typechecker and build both passed; and an Easypanel deploy that segfaulted on the first build — which we proved was not our code before we changed a line of it.

16 min Jul 1, 2026
deblolaunch-dayclaude-opus-4.8claude-code +26