antonta's space
Following patterns wherever they emerge.
main posts

Evolving password-based authentication

Passwords leaving the client side in plain-text are no good for many reasons, everybody knows that. One alternative is to hash the password on the client side and send it to the server, but that has similar problems, just the password is obscured. An improvement, but we can do better. Why not use digital signatures as a method of authentication? Let's explore that further.

Getting these keys right

What we need is a pair of signing and verification keys, which are also called secret and public keys. The server needs to know about public keys only, the client keeps the secret part private. This eliminates the storage of user secrets on the server side. Pretty good.

The trick is to get these key pairs. This is a solved problem - run the password through PBKDF, get the key material, then use a KDF to get the key pair. What would be the salt for PBKDF? I could not come up with anything better than the hash of the username mixed with some unique identifier of the project - the salt has to be deterministic or stored somewhere. But I think we can live with that minor downside here.

The beauty here is that the flow is familiar from the UX side - you enter your username and password, and the rest happens transparently.

What about Passkeys you may ask? It's actually possible to use Passkeys too! Just need a compatible device that supports PRF extension and you are good to go. You can even pack a random salt for PRF into the UserId of a resident key, that's exactly what I did in my current project.

Complicate the client further

What we need is a reliable signature of the request. I came up with the following: make use of existing JWTs, just generate them on the client side with claims related to the request itself and let the server validate the JWT. Since both sides see the same request, there is no concern of forging or anything. Send this JWT in x-client-jwt request header or similar.

The claims could look like this:

struct Claims {
    // Set that to the time of the request + request timeout
    exp: i64,
    // Set that to the time of the request
    iat: i64,
    // HTTP method
    htm: String,
    // Full HTTP endpoint, e.g. https://api.example.com/v1/auth/login?username=foo
    hte: String,
    // The blake3 checksum of the request body
    htb_blake3: Option<String>,
}

The client, before issuing a request, builds the claims, constructs the regular JWT and signs it with the signing key. This whole process can be easily encapsulated in a signedFetch function, or similar.

Note that this approach is susceptible to clock skew between the client and the server. One practical solution is to allow for 2 minute drift in either direction, and send a special error if it's too high to signal the user to synchronize the clock.

Registration and login

The server needs to know about public keys before it can authenticate the user. Here we can go through the same key derivation process, and include the signed public keys (yes, sign them for a good measure) in the register request to the server. The server verifies the client-issued JWT matches the public keys included in the body of the request and verifies the signatures of the public keys themselves. If all is good, the registration is complete! The server stores the signed public keys for future request authentication. Sounds a little convoluted, I know.

Login is straightforward. The client only needs to include the username in the request and sign the request. The server retrieves the public keys associated with the username and verifies the signature of the request. If all is good, the login completes! Isn't that trivial?

In my app the server issues a short-lived session token after successful authentication. That one, to be refreshed, should also go through the client-side request signature process for completeness.

Strictly better

I'd argue this approach is strictly better than any form of plain password authentication. No secret leaves the client. Replay attacks become quite hard. Even MITM won't get those secrets and only the server-issued JWT which will expire after some time. Plus it's fairly easy to implement, and you get a bonus of having signed requests, which can be applied to mutating endpoints. Pretty neat, isn't it?

I actually arrived at that while building an E2EE web app, and went through a few iterations: plain-text password, hashed password, attack protection via client-side request signatures for mutating endpoints, and then... why not use that for authentication too since the client already signs the requests and the server has the public keys? Applies perfectly I think. You get a unified authentication mechanism that differs only in key derivation, preserving the same request signing logic, no mixed auth vulnerabilities and downgrade attacks.

You can even go fancy and use hybrid post-quantum signatures using ML-DSA along with ed25519. Or extend this scheme by supporting multiple credentials simultaneously if the app allows for that - multiple passwords, passkeys, etc., all generate unique public keys that are stored on the server side, and the server verifies the signatures with public keys until they match, or include the fingerprint of the public key on the client side during authentication for the exact match. I did not go that far, and only have a single authentication method due to certain constraints of the app, but this should be doable.

What's interesting is that I checked a few web apps I use daily, some of them are big players out there, and all of them send the password in plain text. One of these is a bank. Yes, a bank with plain text passwords. What a disaster! It's 2025 after all, we can definitely do better.