Building a Secure Crypto Hashing using Vue Composables

I was researching how best I could implement a web-based encryption method without using the popular atob() function in the browser, as that can easily be decrypted by anyone without requiring any form of decryption password. That was when I came across crypto-based hashing.
I found out that cryptographic API has a lot things it handles, starting from secure password handling, data integrity, and authentication. The Web Crypto API provides powerful browser-native tools, but working with it directly is like squid games 😄(the Netflix series). At first, i was seeing a lot of things tthat I believed to be jargons because they were not making sense to me, but with time and a lot of coffee i understood it to an extent, so I will try to make it as simple as possible. Because i use vue, i have created this in a composable for simplicity sake so let’s see below what it looks like.
If you don’t know much about vue composables, you can check it up at: what is a vue compoables
Now let’s start with what a TextEncoder does.
The TextEncoder
is a Web API that converts strings into binary data (specifically, UTF-8 encoded bytes).
An Example is:
const encoder = new TextEncoder()
const text = "Hello World"
const bytes = encoder.encode(text)
console.log(text) // "Hello World" (string)
console.log(bytes) // Uint8Array [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]
The encode()
method transforms each character into its UTF-8 byte representation. For example:
'H' becomes 72
'e' becomes 101
'l' becomes 108
And so on...
Why Crypto Functions Need It
Cryptographic operations work with binary data, not text strings. When you want to hash something like "password123", the crypto functions need the actual bytes that represent those characters not the string itself. It’s just like we were taught in our early days of computer studies: humans understand text and readable content, while computers understand binary (basically numbers and bytes).
// Hashing a password
async function hashSHA256(input: string): Promise<string> {
const data = encoder.encode(input) // Convert string(human readable format) to bytes
const hash = await crypto.subtle.digest('SHA-256', data) // Hash the bytes
return bufferToHex(hash)
}
// PBKDF2 key derivation
const passwordKey = await crypto.subtle.importKey(
'raw',
encoder.encode(password), // Password string → bytes
{ name: 'PBKDF2' },
false,
['deriveBits']
)
// HMAC computation
const keyMaterial = encoder.encode(keyStr) // Key string → bytes
const message = encoder.encode(message) // Message string → bytes
UTF-8 Encoding
TextEncoder specifically uses UTF-8 encoding, which:
Represents ASCII characters (like English letters) as single bytes
Uses multiple bytes for non-ASCII characters (like émojis, accented letters)
Is the standard encoding for web content.
const encoder = new TextEncoder()
console.log(encoder.encode("A")) // [65]
console.log(encoder.encode("é")) // [195, 169] (2 bytes)
console.log(encoder.encode("🚀")) // [240, 159, 154, 128] (4 bytes)
Why Not Just Use String Methods?
You might wonder why not just convert strings to bytes manually. The reason is that UTF-8 encoding is complex:
// ❌ This doesn't work correctly for all characters
String("café").split('').map(c => c.charCodeAt(0)) // [99, 97, 102, 233]
// ✅ TextEncoder handles UTF-8 properly
new TextEncoder().encode("café") // [99, 97, 102, 195, 169]
The é
character needs to be encoded as two bytes (195, 169) in UTF-8
, which charCodeAt()
can't handle correctly.
Security Importance
Using TextEncoder ensures that:
Consistent encoding: The same string always produces the same bytes
Proper UTF-8 handling: International characters are encoded correctly
Predictable hashing: Hash results are consistent across different systems and browsers
Without proper encoding, you could get different hash results for the same logical string, breaking password verification and other security features.
Now i will try and explain what the constantTimeEqual
functions does to wrap up this because it’s already getting too long. But i will drop the rest of the explanation on article two of Building a Secure Crypto Hashing using Vue Composables 2.
The constantTimeEqual
function is a critical security feature that prevents timing attacks.
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let result = 0
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
}
return result === 0
}
This compares two strings character by character, but always takes the same amount of time regardless of where (or if) differences are found.
The Problem with Normal String Comparison
A naive string comparison might look like this:
function unsafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false // Returns immediately when difference found
}
}
return true
}
Note: This is vulnerable to timing attacks.
The issue is that this function returns false
as soon as it finds the first different character, making it faster when differences occur early.
Now what do we mean by timing attack?
Timing Attack Example
Imagine an attacker trying to guess a hash:
const correctHash = "a1b2c3d4e5f6..."
const attempts = [
"x1b2c3d4e5f6...", // Wrong at position 0 → very fast
"a0b2c3d4e5f6...", // Wrong at position 1 → slightly slower
"a1x2c3d4e5f6...", // Wrong at position 2 → even slower
"a1b2x3d4e5f6..." // Wrong at position 3 → slowest so far (lol)
]
By measuring response times, an attacker can deduce:
First attempt was fastest → first character is probably NOT 'x'
Fourth attempt was slowest → first 3 characters are probably correct
This leaks information about the correct value
How constantTimeEqual Prevents This
The function uses bitwise operations to avoid early returns, basically prologing the duration regardless of what string was texted first:
let result = 0
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
}
return result === 0
Step by Step:
- XOR Operation (
^
): Comparing each character
'a'.charCodeAt(0) ^ 'a'.charCodeAt(0) // 97 ^ 97 = 0 (same)
'a'.charCodeAt(0) ^ 'b'.charCodeAt(0) // 97 ^ 98 = 3 (different)
- OR Assignment (
|=
): Accumulating any differences
result = 0
result |= 0 // result = 0 (no difference found yet)
result |= 3 // result = 3 (difference found, stays non-zero)
result |= 0 // result = 3 (still non-zero)
Always processes every character: The loop never breaks early
Final check:
result === 0
only if ALL characters matched
Real World impact? You may ask
In the above photo snippet, you can see i used it in the verifyPassword
function, and why:
async function verifyPassword(
inputPassword: string,
storedSaltBase64: string,
storedDerivedHex: string
): Promise<boolean> {
const derived = await deriveKeyPBKDF2(inputPassword, storedSaltBase64)
return constantTimeEqual(derived, storedDerivedHex) // Safe comparison
}
Without constant-time comparison, an attacker could:
Submit password attempts
Measure response times
Gradually deduce the correct password hash
Eventually crack the password
Why Does This Matters?
Timing attacks are:
Practical: Can be executed over networks
Subtle: Often overlooked by developers
Devastating: Can reveal passwords, tokens, or other secrets
Real: Used in actual security breaches
The constantTimeEqual
function is a simple but crucial defense that ensures your cryptographic comparisons don't leak information through timing variations.
I will stop this here for now cause i need to get another coffee and probably create a different bug to fix 😀
Bye for now.
Subscribe to my newsletter
Read articles from Emmanuel Ufere directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Emmanuel Ufere
Emmanuel Ufere
Emmanuel Ufere (9ice_guy) is an experienced Software Developer with over 4 years of professional expertise in delivering robust, scalable solutions across diverse technical challenges. He specializes in problem-solving complex development requirements while maintaining high code quality and project efficiency. Emmanuel is passionate about fostering collaborative relationships within the tech community, mentoring fellow developers, and contributing to open-source projects. His commitment to knowledge sharing and positive impact drives him to actively support team growth and industry advancement. Known for his reliable delivery and adaptable approach, Emmanuel thrives in challenging environments where innovative solutions are required. He consistently seeks opportunities to contribute his expertise while expanding his technical skillset through continuous learning and community engagement.