DEV Community

Ajay Mourya
Ajay Mourya

Posted on

The Share Link That Worked Everywhere Except When It Mattered

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:

  1. Generated key
  2. Key placed into URL
  3. 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   β”‚
             β”‚              β”‚
Enter fullscreen mode Exit fullscreen mode

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)