WebAuthn.jl

WebAuthn.jl brings passkey and FIDO2/WebAuthn authentication to Julia web servers. It provides end-to-end functions for registration and login, CBOR/COSE key parsing, challenge signing, signature verification, and PEM export.


Experimental

This code is experimental. All cryptographic validation is enforced by OpenSSL or Sodium for safety. Please do not use in production without a full security review.

Features

  • CBOR/COSE key parsing (P-256, RSA, Ed25519)
  • Base64url encode/decode & random challenge generator
  • Registration and authentication options utilities
  • Secure signature and challenge verification
  • PEM export of public keys

Getting Started

  1. Install this package:
    ] add https://github.com/andreeco/WebAuthn.jl
  2. Import into your Julia session:
    using WebAuthn
  3. For Demo Server Example below:
    ] add HTTP, Sockets, JSON3, WebAuthn, Random, CBOR

How WebAuthn Works

WebAuthn enables passwordless, phishing-resistant authentication using public-key cryptography. A passkey (credential) is generated and stored securely on the user device; private keys never leave the device.


Registration Workflow

  1. Server generates options: registration_options — builds challenge and metadata for browser.

  2. Client creates credential: Browser and authenticator generate a new passkey on the device.

  3. Authenticator returns attestation: Browser returns attestationObject and clientDataJSON to your server.

  4. Server verifies registration:

For a simpler, secure approach, call verify_registration_response to run all checks at once!


Authentication Workflow

  1. Server generates assertion options: authentication_options

  2. Client signs with passkey: Browser prompts user; authenticator signs challenge.

  3. Authenticator returns assertion: Server receives: authenticator data, clientDataJSON, signature.

  4. Server verifies signature:

Or use verify_authentication_response for the recommended unified approach.


Core Functions by Flow

PhaseStepWebAuthn.jl Functions
RegistrationBuild optionsregistration_options
Parse & verifyverify_registration_response or parse_attestation_object, parse_clientdata_json, verify_challenge, extract_credential_public_key, cose_key_parse, verify_attestation_object
AuthenticationBuild optionsauthentication_options
Parse & verifyverify_authentication_response or parse_assertion, parse_clientdata_json, verify_challenge, verify_webauthn_signature

See also: cose_key_to_pem for PEM export/interoperation.


Tips

  • All crypto uses OpenSSL_jll and Sodium.jl.
  • Always verify the challenge and client origin in every response.
  • Check signCount to stop replay or clone attacks.
  • For user presence/verification, check flags in authenticator data (see FIDO2 spec).

Demo Server Example

Below is a reference implementation of a simple web server. It serves HTML/JS assets, builds and verifies WebAuthn flows, and manages user passkeys in memory.

Demo screenshots:

How to Test WebAuthn with a Security Key or Phone

  1. With hardware key: Insert device, use when prompted.

  2. With smartphone (virtual security key):

    • Enable Bluetooth on computer and phone and connect them
    • Visit demo site in Chrome; choose "Use phone" when prompted
    • Scan QR code on screen and follow prompts on your phone

Full Example Server High Level

After installing the dependencies, you can copy-paste this code.

using HTTP, Sockets, JSON3, WebAuthn, Random, CBOR

const USERS = Dict{String,Dict{Symbol,Any}}()
const CREDENTIALS = Dict{String,Dict{Symbol,Any}}()
router = HTTP.Router()

function serve_login_success(req)
    params = HTTP.queryparams(req)
    username = get(params, "username", "")
    html = replace(WebAuthn.asset("login_success.html"),
        "{{USERNAME}}" => HTTP.escapehtml(username))
    return HTTP.Response(200, ["Content-Type" => "text/html"], html)
end
HTTP.register!(router, "GET", "/login_success", serve_login_success)

function serve_index(req)
    HTTP.Response(200, ["Content-Type" => "text/html"],
        WebAuthn.asset("index.html"))
end
HTTP.register!(router, "GET", "/", serve_index)

function serve_webauthn_register_js(req)
    HTTP.Response(200, ["Content-Type" => "application/javascript"],
        WebAuthn.asset("webauthn_register.js"))
end
HTTP.register!(router, "GET", "/webauthn_register.js", 
serve_webauthn_register_js)

function serve_webauthn_login_js(req)
    HTTP.Response(200, ["Content-Type" => "application/javascript"],
        WebAuthn.asset("webauthn_login.js"))
end
HTTP.register!(router, "GET", "/webauthn_login.js", serve_webauthn_login_js)

function serve_regoptions(req)
    q = HTTP.queryparams(req)
    username = get(q, "username", "")
    if isempty(username)
        charset = vcat('A':'Z', 'a':'z', '0':'9')
        username = join(rand(charset, 8))
    end
    opts = WebAuthn.registration_options(
        "localhost", "Passkey Demo", username, username, 
        username; exclude_ids=[]
    )
    USERS[username] = Dict(:challenge => opts["challenge"])
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(merge(opts, Dict("username" => username))))
end
HTTP.register!(router, "GET", "/webauthn/options/register", serve_regoptions)

function serve_regfinish(req)
    payload = JSON3.read(String(req.body), Dict{String,Any})
    username = get(payload, "username", "")
    if isempty(username)
        return HTTP.Response(400, ["Content-Type" => "text/plain"], 
        "Missing username")
    end
    chal = get(get(USERS, username, Dict{Symbol,Any}()), :challenge, nothing)
    if chal === nothing
        return HTTP.Response(400, ["Content-Type" => "text/plain"], 
        "No challenge for username.")
    end
    reg_result = verify_registration_response(
        payload;
        expected_challenge=chal,
        expected_origin="http://localhost:8000"
    )
    if !reg_result.ok
        return HTTP.Response(400, ["Content-Type" => "text/plain"], 
        "Registration failed: $(reg_result.reason)")
    end

    pkbytes = extract_credential_public_key(
        parse_attestation_object(
            payload["response"]["attestationObject"])["authData"]
    )
    CREDENTIALS[reg_result.credential_id] = Dict(
        :public_key_cose => WebAuthn.base64urlencode(pkbytes),
        :sign_count => 0,
        :username => username
    )
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(Dict("ok" => true, "username" => username)))
end
HTTP.register!(router, "POST", "/webauthn/register", serve_regfinish)

function serve_loginoptions(req)
    q = HTTP.queryparams(req)
    username = get(q, "username", "")
    allow_ids = String[]
    if !isempty(username)
        allow_ids = [cid for (cid, c) in CREDENTIALS if get(
            c, :username, "") == username]
        if isempty(allow_ids)
            allow_ids = String[]
        end
    else
        allow_ids = collect(keys(CREDENTIALS))
    end
    opts = WebAuthn.authentication_options("localhost",
        allow_credential_ids=allow_ids)
    for cid in allow_ids
        CREDENTIALS[cid][:challenge] = opts["challenge"]
    end
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(merge(opts, Dict("username" => username))))
end
HTTP.register!(router, "GET", "/webauthn/options/login", 
serve_loginoptions)

function serve_loginfinish(req)
    payload = JSON3.read(String(req.body), Dict{String,Any})
    credid = payload["id"]
    if !haskey(CREDENTIALS, credid)
        return HTTP.Response(403, ["Content-Type" => "text/plain"], 
        "Unknown credential")
    end
    cred = CREDENTIALS[credid]
    chal = get(cred, :challenge, nothing)
    if chal === nothing
        return HTTP.Response(400, ["Content-Type" => "text/plain"], 
        "No challenge issued for this credential")
    end
    pubkey_cose_bytes = WebAuthn.base64urldecode(cred[:public_key_cose])
    pubkey_dict = CBOR.decode(pubkey_cose_bytes)
    pubkey = WebAuthn.cose_key_parse(pubkey_dict)
    authn_result = verify_authentication_response(
        payload;
        public_key=pubkey,
        expected_challenge=chal,
        expected_origin="http://localhost:8000",
        previous_signcount=cred[:sign_count],
        require_uv=true
    )
    if !authn_result.ok
        return HTTP.Response(403, ["Content-Type" => "text/plain"], 
        "Authentication failed: $(authn_result.reason)")
    end
    cred[:sign_count] = authn_result.new_signcount
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(Dict(
            "ok" => true,
            "username" => get(cred, :username, ""),
            "redirect" => "/login_success?username=$(
            get(cred, :username, ""))"
        ))
    )
end
HTTP.register!(router, "POST", "/webauthn/login", serve_loginfinish)

srv = HTTP.serve!(router, Sockets.localhost, 8000)

Full Example Server Low Level

After installing the dependencies, you can copy-paste this code.

using HTTP, Sockets, JSON3, WebAuthn, Random, CBOR

USERS = Dict{String,Dict{Symbol,Any}}()
CREDENTIALS = Dict{String,Dict{Symbol,Any}}()
router = HTTP.Router()

function serve_login_success(req)
    params = HTTP.queryparams(req)
    username = get(params, "username", "")
    html = replace(WebAuthn.asset("login_success.html"),
        "{{USERNAME}}" => HTTP.escapehtml(username))
    return HTTP.Response(200, ["Content-Type" => "text/html"], html)
end
HTTP.register!(router, "GET", "/login_success", serve_login_success)

function serve_index(req)
    HTTP.Response(200, ["Content-Type" => "text/html"],
        WebAuthn.asset("index.html"))
end
HTTP.register!(router, "GET", "/", serve_index)

function serve_webauthn_register_js(req)
    HTTP.Response(200, ["Content-Type" => "application/javascript"],
        WebAuthn.asset("webauthn_register.js"))
end
HTTP.register!(router, "GET", "/webauthn_register.js",
    serve_webauthn_register_js)

function serve_webauthn_login_js(req)
    HTTP.Response(200, ["Content-Type" => "application/javascript"],
        WebAuthn.asset("webauthn_login.js"))
end
HTTP.register!(router, "GET", "/webauthn_login.js", serve_webauthn_login_js)

function serve_regoptions(req)
    q = HTTP.queryparams(req)
    username = get(q, "username", "")
    if isempty(username)
        charset = vcat('A':'Z', 'a':'z', '0':'9')
        username = join(rand(charset, 8))
    end
    opts = WebAuthn.registration_options(
        "localhost", "Passkey Demo", username, username, username;
        exclude_ids=[]
    )
    USERS[username] = Dict(:challenge => opts["challenge"])
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(merge(opts, Dict("username" => username))))
end
HTTP.register!(router, "GET", "/webauthn/options/register", serve_regoptions)

function serve_regfinish(req)
    payload = JSON3.read(String(req.body))
    username = get(payload, "username", "")
    if isempty(username)
        return HTTP.Response(400, ["Content-Type" => "text/plain"],
            "Missing username")
    end
    chal = get(USERS[username], :challenge, nothing)
    if chal === nothing
        return HTTP.Response(400, ["Content-Type" => "text/plain"],
            "No challenge for username.")
    end
    if !WebAuthn.verify_challenge(
        payload["response"]["clientDataJSON"], chal)
        return HTTP.Response(400, ["Content-Type" => "text/plain"],
            "Challenge fail")
    end
    attobj = WebAuthn.parse_attestation_object(
        payload["response"]["attestationObject"])
    pkbytes = WebAuthn.extract_credential_public_key(attobj["authData"])
    cred_id = payload["id"]
    CREDENTIALS[cred_id] = Dict(
        :public_key_cose => WebAuthn.base64urlencode(pkbytes),
        :sign_count => 0,
        :username => username
    )
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(Dict("ok" => true, "username" => username)))
end
HTTP.register!(router, "POST", "/webauthn/register", serve_regfinish)

function serve_loginoptions(req)
    q = HTTP.queryparams(req)
    username = get(q, "username", "")
    allow_ids = String[]
    if !isempty(username)
        allow_ids = [cid for (cid, c) in CREDENTIALS if get(
            c, :username, "") == username]
        if isempty(allow_ids)
            allow_ids = String[]
        end
    else
        allow_ids = collect(keys(CREDENTIALS))
    end
    opts = WebAuthn.authentication_options("localhost",
        allow_credential_ids=allow_ids)
    for cid in allow_ids
        CREDENTIALS[cid][:challenge] = opts["challenge"]
    end
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(merge(opts, Dict("username" => username))))
end
HTTP.register!(router, "GET", "/webauthn/options/login", serve_loginoptions)

function serve_loginfinish(req)
    payload = JSON3.read(String(req.body))
    credid = payload["id"]
    if !haskey(CREDENTIALS, credid)
        return HTTP.Response(403, ["Content-Type" => "text/plain"],
            "Unknown credential")
    end
    cred = CREDENTIALS[credid]
    chal = get(cred, :challenge, nothing)
    if chal === nothing
        return HTTP.Response(400, ["Content-Type" => "text/plain"],
            "No challenge issued for this credential")
    end
    if !WebAuthn.verify_challenge(payload["response"]["clientDataJSON"], chal)
        return HTTP.Response(400, ["Content-Type" => "text/plain"],
            "Challenge fail")
    end
    pubkey = WebAuthn.cose_key_parse(
        CBOR.decode(WebAuthn.base64urldecode(cred[:public_key_cose])))
        ad = WebAuthn.base64urldecode(payload["response"]["authenticatorData"])
    cdj = WebAuthn.base64urldecode(payload["response"]["clientDataJSON"])
    sig = WebAuthn.base64urldecode(payload["response"]["signature"])
    cdj_dict = WebAuthn.parse_clientdata_json(payload["response"]["clientDataJSON"])
    ok = WebAuthn.verify_webauthn_signature(pubkey, ad, cdj, sig)
    username = get(cred, :username, "")
    if !ok
        return HTTP.Response(403, ["Content-Type" => "text/plain"], "Bad signature")
    end
    verify_origin(cdj_dict, "http://localhost:8000")
    old_signcount = cred[:sign_count]
    new_signcount = reinterpret(UInt32, ad[34:37])[1]
    enforce_signcount(old_signcount, new_signcount)
    cred[:sign_count] = new_signcount
    enforce_up_uv(ad; require_uv=false)
    return HTTP.Response(200, ["Content-Type" => "application/json"],
        JSON3.write(Dict("ok" => true, "username" => username,
            "redirect" => "/login_success?username=$username"))
    )
end
HTTP.register!(router, "POST", "/webauthn/login", serve_loginfinish)

srv = HTTP.serve!(router, Sockets.localhost, 8000)

Contributions

Questions, issues, and PRs welcome! See WebAuthn.jl on GitHub.


License

Licensed under the MIT License. See LICENSE for details.