WebAuthn & Passkeys in OCaml: Implementing Passwordless Authentication

The implementation of modern passwordless authentication using WebAuthn and Passkeys in OCaml, featuring binary data handling challenges, Base64 encoding strategies, and validation infrastructure.

Project: Chaufr – Personal drivers, on demand, in your own vehicle
Tech Stack: OCaml 5.3.0, Dream Framework, WebAuthn 0.2.0, PostgreSQL, Caqti, Base64

I got the inspiration to add WebAuthn from Yawaramin post on X and it evolved into a deep exploration of binary data handling in OCaml, proper cryptographic storage patterns, and building a complete passwordless authentication infrastructure. This post shares this project's transformation from basic password authentication to a modern WebAuthn/Passkeys system that supports both users and drivers.


Key Accomplishments

1. 🔐 WebAuthn Package Integration

Challenge: The OCaml ecosystem has limited WebAuthn library options, requiring careful package selection and integration.

Solution: Integrated webauthn v0.2.0 package with proper dependency management and API understanding.

Package Installation and Configuration

(* dune-project *)
(package
 (name chaufr)
 (depends
  ocaml
  dune
  (* ... other dependencies ... *)
  webauthn                        (* New: WebAuthn library *)
  opentelemetry
  opentelemetry-client-ocurl))

WebAuthn Library Integration

let create_webauthn origin =
  match Webauthn.create origin with
  | Ok wa -> wa
  | Error e -> failwith (Printf.sprintf "WebAuthn init failed: %s" e)

Integration Features:

Key Insight: The WebAuthn library provides register and authenticate functions handle challenge generation and return a (challenge, base64_string) tuple.

2. 🗄️ PostgreSQL Binary Data Storage

Challenge: WebAuthn credentials (credential_id and public_key) are binary data requiring proper storage as PostgreSQL bytea type, but the application models work with Base64 strings.

Solution: Implemented comprehensive binary data handling with bytea storage and Base64 encoding layer at the service boundary.

Database Schema with Binary Storage

CREATE TABLE IF NOT EXISTS passkeys (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_id uuid NOT NULL,        -- references users.id or drivers.id
  owner_type varchar(20) NOT NULL, -- 'user' or 'driver'
  credential_id bytea NOT NULL,  -- Binary credential identifier
  public_key bytea NOT NULL,     -- Binary public key (P256 curve)
  sign_count bigint NOT NULL DEFAULT 0,
  transports text,               -- Optional metadata (JSON/text)
  display_name text,             -- User-friendly name
  created_at timestamptz DEFAULT now(),
  last_used timestamptz
);

CREATE UNIQUE INDEX IF NOT EXISTS passkeys_credential_idx 
  ON passkeys(credential_id);
CREATE INDEX IF NOT EXISTS passkeys_owner_idx 
  ON passkeys(owner_id, owner_type);

Caqti Query Types with Octets

open Database.Queries
open Caqti_request.Infix

(* Using octets type for bytea columns *)
let insert_passkey_q =
  Caqti_type.(
    t8 uuid uuid string octets octets int32 (option string) (option string)
    ->! uuid)
  @@ "INSERT INTO passkeys (id, owner_id, owner_type, credential_id, \
      public_key, sign_count, transports, display_name) VALUES \
      ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id"

let select_passkey_by_credential_id_q =
  Caqti_type.(
    octets
    ->! t9 uuid uuid string octets octets int32 (option string) (option string)
          string)
  @@ "SELECT id, owner_id, owner_type, credential_id, public_key, sign_count, \
      transports, display_name, to_char(created_at,'YYYY-MM-DD HH24:MI:SS') \
      FROM passkeys WHERE credential_id = $1"

Binary Storage Benefits:

Critical Architecture Decision: Store credentials as bytea in PostgreSQL (correct for WebAuthn binary data) but maintain Base64 string representation in application models for API compatibility.

3. 📦 Base64 Encoding Layer Implementation

Challenge: Bridge the gap between binary database storage (bytea) and string-based application models (Base64) without data corruption.

Solution: Implemented a comprehensive Base64 encoding/decoding infrastructure with proper error handling.

Base64 Helper Functions

type conversion_error = string list

(* Base64 encoding/decoding helpers for binary data (bytea <-> base64 string) *)
let base64_decode_binary (encoded : string) : (string, string) result =
  match Base64.decode ~pad:true encoded with
  | Ok decoded -> Ok decoded
  | Error (`Msg msg) -> Error msg

let base64_encode_binary (data : string) : (string, string) result =
  match Base64.encode ~pad:true data with
  | Ok encoded -> Ok encoded
  | Error (`Msg msg) -> Error msg

Model Conversion with Base64 Integration

(* Passkey model to database insert parameters *)
let passkey_model_to_insert_params (p : Passkey.t) :
    ( Uuidm.t
      * Uuidm.t
      * string
      * string
      * string
      * int32
      * string option
      * string option,
      conversion_error )
    result =
  match Passkey.validate p with
  | Ok () -> (
      let id =
        match p.id with
        | Some s -> Option.get (Uuidm.of_string s)
        | None -> gen_uuid ()
      in
      let owner_id = Option.get (Uuidm.of_string p.owner_id) in
      (* credential_id and public_key are stored as Base64 strings in model,
         decode to binary for database bytea storage *)
      match
        (base64_decode_binary p.credential_id, base64_decode_binary p.public_key)
      with
      | Ok cred_bytes, Ok pk_bytes ->
          Ok
            ( id,
              owner_id,
              p.owner_type,
              cred_bytes,
              pk_bytes,
              p.sign_count,
              p.transports,
              p.display_name )
      | Error msg, _ -> Error [ "Invalid credential_id: " ^ msg ]
      | _, Error msg -> Error [ "Invalid public_key: " ^ msg ])
  | Error errs -> Error errs

Service Layer Base64 Handling

let create_passkey ~owner_id ~owner_type ~credential_id ~public_key ~sign_count
    ~transports ~display_name =
  match Uuidm.of_string owner_id with
  | None -> Lwt.return (Error (`Query_error "Invalid owner UUID"))
  | Some owner_uuid -> (
      (* Decode Base64 strings to binary for DB storage *)
      match
        ( Base64.decode ~pad:true credential_id,
          Base64.decode ~pad:true public_key )
      with
      | Error (`Msg msg), _ ->
          Lwt.return
            (Error (`Query_error ("Invalid credential_id Base64: " ^ msg)))
      | _, Error (`Msg msg) ->
          Lwt.return
            (Error (`Query_error ("Invalid public_key Base64: " ^ msg)))
      | Ok cred_bytes, Ok pk_bytes -> (
          owner_exists ~owner_id ~owner_type >>= fun exists ->
          if not exists then Lwt.return (Error (`Query_error "Owner not found"))
          else
            let id =
              match v7_monotonic () with
              | Some u -> u
              | None ->
                  Uuidm.v7_non_monotonic_gen ~now_ms:Time.posix_now_ms
                    rand_state ()
            in
            PasskeyQ.create ~id ~owner_id:owner_uuid ~owner_type
              ~credential_id:cred_bytes ~public_key:pk_bytes ~sign_count
              ~transports ~display_name
            >>= function
            | Ok id -> Lwt.return (Ok (Uuidm.to_string id))
            | Error e -> Lwt.return (Error e)))

Base64 Layer Benefits:

Challenge: Implement proper counter management to prevent replay attacks per WebAuthn specification.

Solution: A comprehensive counter update mechanism with timestamp tracking.

Counter Update Implementation

let update_passkey_counter ~passkey_id ~sign_count =
  match Uuidm.of_string passkey_id with
  | None -> Lwt.return (Error (`Query_error "Invalid passkey UUID"))
  | Some id -> (
      PasskeyQ.update_counter ~id ~sign_count ~last_used:None >>= function
      | Ok _ -> Lwt.return (Ok ())
      | Error e -> Lwt.return (Error e))

Counter Management Features:

Security Note: The WebAuthn spec allows counter values of zero-to-zero transitions for certain authenticator types (e.g., Touch ID), requiring flexible validation logic in the authentication handler.


Technical Deep Dive

Data Flow Architecture

Complete WebAuthn Registration Flow

1. Client (Browser)
   ↓ GET /auth/register/challenge
2. Handler (auth.ml)
   ↓ Generate challenge
3. WebAuthn Library
   ↓ Return (challenge, base64_challenge)
4. Handler → Client
   ↓ navigator.credentials.create()
5. Client → Handler
   ↓ POST /auth/register/finish
6. Handler validates attestation
   ↓ Extract credential_id (Base64) and public_key (Base64)
7. Service Layer
   ↓ Decode Base64 to binary
8. Repository Layer (octets type)
   ↓ Store as bytea
9. PostgreSQL
   ✓ Credential stored

Authentication Flow with Base64 Conversion

1. Client → Handler
   ↓ POST /auth/login (credential_id in Base64)
2. Service Layer
   ↓ Decode Base64 credential_id to binary
3. Repository Query
   ↓ SELECT WHERE credential_id = $1 (bytea)
4. PostgreSQL
   ↓ Return passkey row (bytea fields)
5. Service Layer
   ↓ Encode bytea to Base64 for model
6. Handler
   ↓ Verify assertion with WebAuthn library
7. Update Counter
   ↓ Increment sign_count
8. Create Session
   ✓ User authenticated

1. 🐛 Binary Data Type Discovery

Solution:

let insert_passkey_q =
  Caqti_type.(
    t8 uuid uuid string octets octets int32 (option string) (option string)
    ->! uuid)
  (* Using 'octets' for bytea columns - CORRECT! *)

Lessons Learned:

2. 🔄 Base64 Encoding Strategy Evolution

Challenge: Multiple approaches for handling Base64 encoding emerged during implementation.

Final Decision: Encode at Service Layer

(* Service layer manages boundary *)
let create_passkey ~credential_id ~public_key =
  match Base64.decode ~pad:true credential_id with
  | Ok bytes -> (* Continue with repository *)
  | Error _ -> (* Handle error *)

Chosen: Clean separation of concerns, service layer manages the boundaries

Lessons Learned:


Performance and Security Considerations

Binary Storage Performance

  1. Database Storage Efficiency

    • bytea storage: ~32-64 bytes for credential_id
    • Text storage: ~64-128 bytes (Base64 overhead)
    • Result: 50% storage savings with bytea
  2. Index Performance

    • PostgreSQL B-tree index on the bytea credential_id
    • Direct binary comparison (no decoding)
    • Result: Fast credential lookup for authentication
  3. Network Transfer

    • The API transfers Base64 strings (JSON-compatible)
    • The database transfers binary data (efficient)
    • Result: Optimal for both interfaces

Conclusion

Whether this significant effort was ultimately worth the benefit remains to be seen. It was, however, an enjoyable challenge, particularly in handling binary data and learning the WebAuthn in OCaml ecosystem.


What a day!

Hey, this site is part of ring.muhokama.fun!