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:
// 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:
fn 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.
use 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:
use 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 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.
static 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:
// Setup wizard flow
step 1: "Create Admin Account"
- Email input (required)
- Password input (minimum 8 characters)
- Confirm password inputstep 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.
pub 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 minutesstore_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.
2. 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.
pub 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
// 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