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:
- ✅ WebAuthn 0.2.0 library with OCaml 5.3.0 compatibility
- ✅ Automatic CBOR dependency resolution (cbor 0.5)
- ✅ Integration with Dream web framework
- ✅ Handler structure for registration and authentication flows
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:
- ✅ Efficient storage of cryptographic material
- ✅ Direct binary comparison for credential lookup
- ✅ PostgreSQL indexing on binary data
- ✅ Alignment with WebAuthn specification requirements
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:
- ✅ Clean separation between storage and application representations
- ✅ Comprehensive error handling for encoding/decoding failures
- ✅ Automatic conversion at service layer boundaries
- ✅ API-friendly Base64 strings for HTTP/JSON interfaces
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:
- ✅ Sign count tracking per credential
- ✅ Last used timestamp updates
- ✅ Replay attack prevention support
- ✅ WebAuthn specification compliance
- ✅ Automatic counter incrementation
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:
-
✅ Always use
Caqti_type.octetsfor PostgreSQLbyteacolumns - ✅ Test binary data round-trip to catch encoding issues early
- ✅ SQL schema must align with Caqti type declarations
- ✅ Base64 layer prevents binary data corruption
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:
- ✅ Service layer is the proper boundary for encoding concerns
- ✅ Keep repository layer focused on data access only
- ✅ Model mappers handle type conversion, not encoding
- ✅ Explicit error handling at each encoding step
Performance and Security Considerations
Binary Storage Performance
-
Database Storage Efficiency
byteastorage: ~32-64 bytes for credential_id- Text storage: ~64-128 bytes (Base64 overhead)
- Result: 50% storage savings with bytea
-
Index Performance
- PostgreSQL B-tree index on the bytea credential_id
- Direct binary comparison (no decoding)
- Result: Fast credential lookup for authentication
-
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!