This is a submission for "DEV's Summer Bug Smash: Smash Stories" (https://dev.to/bugsmash) powered by "Sentry" (https://sentry.io/).
The Share Link That Worked Everywhere Except When It Mattered
There is a special kind of bug that makes you question whether HTTP, browsers, JavaScript, databases, and reality itself have agreed to work against you.
This was one of those bugs.
ShareText is deliberately simple: write some text, create a shareable link, and let someone else open it later. The application uses a Spring Boot backend, a browser-based client, persistent storage, optional expiration, optional passwords, and client-side encryption.
The feature worked beautifully.
Until one day, a perfectly valid share link opened successfully for one personβand produced an "Access Error" for another.
No database error.
No HTTP 500.
No obvious backend failure.
The link existed.
The API returned the data.
The ciphertext looked fine.
And yet the browser couldn't decrypt it.
That was the beginning of the hunt.
The crime scene
The first reproduction looked almost insulting:
Create share
β
Copy URL
β
Open URL
β
π₯ Access Error
But refreshing the page sometimes changed the result.
Opening the same link in another browser could produce a different outcome.
And the backend logs looked completely healthy.
The server was doing exactly what it was supposed to do:
GET /api/text/{id}
200 OK
The database returned the record.
The client received ciphertext.
So why couldn't the client decrypt it?
The first suspect: the database
Naturally, the database got blamed first.
The stored content looked something like:
3f8e4a9d2d......
It wasn't empty.
It wasn't truncated.
It was different for every share.
So I compared the value stored in the database with the value returned by the API.
They matched.
The database was innocent.
One suspect eliminated.
The second suspect: encryption
Next came the cryptography.
ShareText's browser encryption uses AES-GCM.
The encryption flow is roughly:
const key = await crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256
},
true,
["encrypt", "decrypt"]
);
A random IV is generated, the plaintext is encrypted, and the resulting payload is stored.
Nothing obviously wrong there.
I added logging around the encryption and decryption boundaries.
The encryption function produced ciphertext.
The decryption function received ciphertext.
But the decryption key sometimes wasn't what I expected.
That was the clue.
The URL was lying to me
The share link looked innocent:
https://sharetext.example/#abc123.
The application uses the URL fragment to carry information needed by the browser.
That's useful because the fragment isn't sent to the server.
But fragments have one particularly annoying characteristic:
they belong to the browser, not the HTTP request.
That means there are now effectively two different versions of the URL:
What the user sees:
https://sharetext.example/#abc123.SECRETKEY
What the server receives:
GET / HTTP/1.1
Host: sharetext.example
The server never sees the fragment.
That's intentional.
But it also means any mistake in client-side URL parsing can completely break the cryptographic flow without producing a single backend error.
The aha moment
The application extracted the share ID and key by splitting the URL fragment.
Conceptually:
const hash = window.location.hash.substring(1);
const separator = hash.lastIndexOf('.');
const shareId = hash.substring(0, separator);
const contentKey = hash.substring(separator + 1);
That code looks harmless.
Until you remember that URLs are not just strings.
They're structured data.
Characters can be encoded.
Characters can be decoded.
Characters can be normalized.
And cryptographic keys are very unforgiving about even one changed character.
One character missing from a key doesn't mean:
"slightly wrong key"
It means:
DECRYPTION FAILED
That was the moment the bug stopped looking like an encryption bug.
It became a serialization bug.
The debugging experiment
I stopped looking at the plaintext.
I stopped looking at the ciphertext.
Instead, I logged three things:
- Generated key
- Key placed into URL
- Key recovered from URL
And compared them byte-for-byte.
The result was beautiful.
Because they weren't equal.
The key generated by Web Crypto was valid.
The key exported by the application was valid.
But the string that came back out of the URL was not always identical.
The browser wasn't failing to decrypt.
We were sometimes giving it a different key.
Why this was so deceptive
The failure appeared at the final step:
decrypt(ciphertext, key)
So that's where we initially looked.
But the actual failure was several operations earlier:
CryptoKey
β
raw bytes
β
string encoding
β
URL
β
URL parsing
β
string decoding
β
raw bytes
β
CryptoKey
The cryptographic primitive was perfectly happy.
The data travelling into it wasn't.
This is one of my favorite classes of bugs:
Β«The error happens at A, but the bug happened at F.Β»
The fix
The fix was to stop treating cryptographic material like arbitrary URL text.
Instead of relying on ambiguous string transformations, the key representation needed to be explicitly URL-safe.
The pipeline became:
CryptoKey
β
raw key bytes
β
URL-safe encoding
β
URL fragment
β
URL-safe decoding
β
raw key bytes
β
CryptoKey
Now the invariant became very simple:
decoded(encode(key)) === key
Not:
"looks roughly the same"
Not:
"works in Chrome"
Exactly the same bytes.
Before
The dangerous mental model was:
key β string β URL β string β key
That sounds harmless.
For cryptographic material, it isn't enough.
After
The new mental model was:
key bytes
β
explicit URL-safe representation
β
URL
β
explicit URL-safe decoding
β
same key bytes
The encryption algorithm didn't change.
The database didn't change.
The backend didn't need to know anything about the key.
We fixed the boundary between cryptography and URL serialization.
The regression test that finally made me happy
The most important test wasn't:
Β«"Can I decrypt a message?"Β»
It was:
Β«"Can I serialize and deserialize the key 10,000 times without changing a single byte?"Β»
The test conceptually became:
const original = randomKeyBytes();
const encoded = encodeForUrl(original);
const decoded = decodeFromUrl(encoded);
expect(decoded).toEqual(original);
Then I tested the nasty cases:
ASCII
Unicode
URL-special characters
long keys
empty fragments
malformed fragments
truncated keys
extra separators
The important lesson was that cryptographic tests need to test the transport around the crypto, not just the crypto primitive.
And then another bug appeared
Of course it did.
Once the decryption problem was fixed, an expired share exposed another edge case.
The backend correctly rejected expired content.
But the frontend treated several different failures as the same generic access error.
From a user's perspective:
Wrong password
Expired link
Invalid link
Missing link
Decryption failure
could all become variations of:
Β«"Access Error."Β»
Technically correct.
Practically terrible.
So I split the failure states.
Now the application could distinguish:
404 β Share doesn't exist
403 β Password required / incorrect password
410 β Share expired
Decrypt failure β Link/key/ciphertext problem
That small change made debugging dramatically easier.
It also made the application feel much more trustworthy.
The resilience lesson
The biggest improvement wasn't the original bug fix.
It was adding explicit boundaries.
The system now has clearly defined contracts:
URL layer
A share URL must contain a valid share identifier
and a valid encoded encryption key.
API layer
The API transports ciphertext.
It does not understand the encryption key.
Database layer
Stored content is ciphertext.
Crypto layer
A key + ciphertext + correct metadata
must deterministically produce plaintext.
UI layer
Different failure modes should produce
different user-visible errors.
Each boundary became testable independently.
What made this bug particularly nasty
The application was not completely broken.
That's what made it dangerous.
Most links worked.
Some links worked in one environment.
The backend returned "200 OK".
The database contained valid-looking ciphertext.
The encryption algorithm was correct.
The key was valid.
The URL was valid.
The failure only appeared when those components interacted.
That meant unit-testing the encryption function alone would never have found it.
The bug lived in the gap between components.
What I learned
I've started treating serialization as part of the security boundary.
Before this bug, I thought about encryption roughly like this:
plaintext
β
AES
β
ciphertext
Now I think about it like this:
plaintext
β
encryption
β
binary data
β
serialization
β
transport
β
deserialization
β
binary data
β
decryption
Every arrow is capable of introducing a bug.
Especially when the thing being serialized is a cryptographic key.
The final architecture
The beautiful thing about debugging a chaotic bug is that the final system often ends up simpler than the original one.
The final flow is:
ββββββββββββββββ
β Browser β
β β
Plaintext βββββββΊβ Encrypt β
β β β
ββββββββΌββββββββ
β
Ciphertext
β
βΌ
ββββββββββββββββ
β Server β
β β
β Store β
β ciphertext β
ββββββββ¬ββββββββ
β
βΌ
Database
Encryption key
β
βΌ
URL-safe encoding
β
βΌ
URL fragment
β
βΌ
Browser
The server doesn't need the key.
The database doesn't need the key.
And the browser can recover exactly the same key it originally generated.
The win
The bug looked like an AES problem.
It wasn't.
It looked like a backend problem.
It wasn't.
It looked like a database problem.
It wasn't.
It was a tiny mismatch between bytes and strings, hiding inside a system where one changed character meant an entire cryptographic operation had to fail.
That's what made the bug memorable.
The fix wasn't:
Β«"Change one line."Β»
The fix was understanding the entire journey of the data.
From:
plaintext
to:
ciphertext
to:
URL
and finally back to:
plaintext
Once every boundary had an explicit contract, the chaos disappeared.
And the share link finally became what it was supposed to be:
a link carrying data to the server, while keeping the key out of the server's hands.
What I would watch in production
For a production deployment, this is also where observability becomes valuable.
A monitoring system such as "Sentry" (https://sentry.io/) could track decryption failures and URL parsing failures without collecting the secrets themselves.
The telemetry should contain things like:
- operation: "decrypt"
- encryption version
- browser/runtime
- share ID
- ciphertext length
- failure category
But never:
- plaintext
- encryption key
- password
- complete secret-bearing URL
The goal isn't merely to know that something failed.
It's to know whether a particular class of failures suddenly increasedβwithout turning observability into another security problem.
Final takeaway
The most dangerous bugs aren't always the ones that crash the application.
Sometimes they're the ones where:
the server returns "200 OK", the database looks healthy, the cryptography is correctβand the user still can't open the link.
Those are the bugs worth smashing.
Because once you find them, you don't just make the software work.
You make the boundaries around the software stronger.
Repository
"ShareText β GitHub" (https://github.com/ajaym0urya/ShareText)
Suggested tags
"#bugsmash" "#sentry" "#javascript" "#java" "#springboot" "#security" "#webdev" "#encryption"
A browser on one side, a Spring Boot server/database on the other, with a cryptographic key travelling through the URL fragment and a red broken link between the two.
Top comments (0)