This is a submission for "DEV's Summer Bug Smash: Smash Stories" (https://dev.to/bugsmash) powered by "Sentry" (https://sentry.io/).
The Encryption Bug That Looked Like a Backend Problem
I thought I was fixing encryption.
What I actually had to fix was where encryption happened.
That distinction turned out to matter more than the cipher itself.
ShareText is a small application for creating links containing text that can optionally expire or require a password. The original architecture had encryption logic on the server. The security model I wanted, however, was stronger: the server should never receive the plaintext at all.
The interesting part wasn't writing AES-GCM.
The interesting part was making the browser, URL, API, database, and backend all agree on what "encrypted" actually meant.
The chaos
The application has a simple flow:
- A user writes text.
- The browser creates a share link.
- The backend stores the shared content.
- Someone opens the link.
- The backend returns the stored payload.
- The browser displays the original text.
That sounds straightforward.
But once the requirement became:
«"The server should only ever see ciphertext."»
the data flow had to change completely.
The repository's eventual design uses browser-side AES-256-GCM. A fresh key is generated for every share, while the key is placed in the URL fragment. The fragment is deliberately outside the HTTP request, meaning the backend receives the ciphertext but not the key. The README documents exactly this model.
The final implementation generates the AES-256-GCM key in the browser, creates a random 12-byte nonce, encrypts the text, combines nonce + ciphertext, and exports the raw key for the share URL.
The difficult part was realizing that this wasn't merely an encryption-function change.
It was a trust-boundary change.
The first misleading clue
The backend still looked like an application that knew how to handle the content.
"SharedTextService" receives the request and creates a "SharedText" entity. It then assigns:
text.setContent(request.getContent());
and persists it:
repository.save(text);
At first glance, that looks like the server is still storing plaintext.
And that was exactly the trap.
The browser had already changed what "request.getContent()" meant.
The client now encrypts the text before making the POST request:
const encryptedContent = await encryptContent(textContent.value);
const payload = {
content: encryptedContent.ciphertext,
expirationDate: calculateExpiry(expirationSelect.value),
password: sharePassword.value.trim() || null,
customAlias: customLinkAlias.value.trim() || null
};
The backend isn't supposed to decrypt this anymore. It simply stores the ciphertext.
So the line that looked suspicious in the backend wasn't actually the bug.
It was the clue that led to the real architectural change.
Following the payload
I traced the content through the application instead of looking at individual functions in isolation.
Before
The older implementation used a server-side encryption utility.
The historical diff shows that "SharedTextController" previously decrypted incoming payload fields using "PayloadCrypto.decrypt(...)". That included the shared content, password and custom alias.
The old browser implementation also used a fixed key for AES-CBC encryption. The historical code even contained the key bytes in the client.
That creates a fundamental problem:
If the browser and server share a fixed encryption key, the server necessarily has the ability to decrypt the data.
That's encryption, but it isn't the privacy boundary I wanted.
After
The new client generates a completely new AES-GCM key for each share:
const key = await window.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
It then generates a random nonce:
const nonce = window.crypto.getRandomValues(
new Uint8Array(12)
);
and encrypts the content:
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce },
key,
new TextEncoder().encode(content)
);
The key never becomes part of the API request.
Instead, the final link is assembled as:
const link =
${window.location.origin}${window.location.pathname} +
#${data.id}.${encryptedContent.key};
That URL fragment is the critical trick.
The browser can use it.
The HTTP server doesn't receive it as part of the request.
The "aha!" moment
The aha moment was realizing that the backend did not need to know how to decrypt the content anymore.
That sounds obvious after the fact.
It wasn't obvious when looking at the code one class at a time.
The API still accepts a "content" field.
The database still has a "content" column.
The service still calls "setContent()".
The access endpoint still returns "data.content".
So if you only inspect the backend, it looks like plaintext is still flowing through the system.
But the meaning of that field has changed.
It is now:
plaintext
↓
Browser
↓
AES-256-GCM
↓
ciphertext
↓
HTTP request
↓
Spring Boot
↓
MySQL
On the way back:
MySQL
↓
ciphertext
↓
Spring Boot
↓
browser
↓
URL fragment provides key
↓
AES-256-GCM decrypt
↓
plaintext
The current access flow confirms this separation. The backend returns the stored content, while the browser calls "decryptContent(data.content, contentKey)" before displaying it.
That was the real fix.
Not "encrypt the string."
Move the encryption boundary.
Why AES-GCM changed the debugging story
The previous implementation used AES-CBC with a fixed key.
The new implementation uses AES-GCM.
That isn't just a cosmetic replacement.
AES-GCM gives us authenticated encryption: tampering with the ciphertext should cause decryption to fail rather than silently producing corrupted plaintext.
The client stores the nonce together with the ciphertext:
const combined = new Uint8Array(
nonce.length + ciphertext.byteLength
);
combined.set(nonce);
combined.set(new Uint8Array(ciphertext), nonce.length);
On decryption, the first 12 bytes are extracted as the IV:
{
name: 'AES-GCM',
iv: combined.slice(0, 12)
}
and the remainder is passed to the authenticated decryption operation.
That gives the application a useful property:
If someone modifies the stored ciphertext, the browser should reject it instead of treating the modified bytes as valid content.
The URL was part of the cryptographic design
This was probably the cleverest part of the change.
A normal URL looks roughly like:
https://sharetext.example/abc123
The new application effectively creates:
https://sharetext.example/#abc123.
The browser parses the fragment:
const hash = window.location.hash.substring(1);
const separator = hash.lastIndexOf('.');
showReadView(
hash.substring(0, separator),
hash.substring(separator + 1)
);
The server only needs the first part — the share ID.
The browser keeps the second part — the decryption key.
This means the application doesn't need to invent another key-storage API.
The URL itself becomes the delivery mechanism.
The fix was bigger than one line
The historical PR shows that this change wasn't a one-line patch.
PR #1, "e41f95c", changed six files and removed the server-side "PayloadCrypto" implementation entirely.
The controller stopped decrypting incoming payload fields.
The browser gained encryption/decryption.
The backend became a ciphertext storage and retrieval layer.
The README was updated to document the new security model.
And the application configuration was changed as part of the migration.
That is an important lesson for security fixes:
Changing the algorithm without changing the architecture can leave the original trust problem intact.
The subtle migration problem
Then came the uncomfortable part.
Changing the encryption model doesn't magically transform old database rows.
The repository's README explicitly calls this out:
«Existing rows created before this encryption change contain plaintext and must be migrated or deleted before deployment.»
That's one of the most important details in the whole change.
The database entity itself is intentionally uncomplicated:
@column(columnDefinition = "LONGTEXT", nullable = false)
private String content;
The database doesn't know whether that string is plaintext or ciphertext.
That means encryption state is really a data contract, not merely an implementation detail.
If old plaintext and new ciphertext coexist without a migration strategy, the frontend can't reliably know what it is supposed to decrypt.
That is exactly the kind of problem that can survive compilation, deployment and basic manual testing.
Password protection was a separate layer
ShareText also supports optional passwords.
The application does not store the password itself. Instead, the backend uses a "PasswordEncoder" and stores the resulting hash.
On access, the service checks:
passwordEncoder.matches(
request.getPassword(),
text.getPasswordHash()
)
before returning the encrypted content.
This gives the application two different security layers:
Password protection
password → BCrypt hash → database
Content confidentiality
text → AES-GCM → ciphertext → database
Those are different problems and should remain different.
What made this tricky
Several things made the change deceptively easy to misunderstand.
- The backend still has a "content" field
The backend didn't suddenly stop storing "content."
It stopped storing plaintext content.
That semantic change isn't visible from the entity class alone.
- The API still returns the content
This can look like a security regression until you follow the URL fragment and client-side decryption.
The server returns ciphertext; the browser turns it back into plaintext.
- The key is intentionally absent from the API
The key isn't missing accidentally.
It's missing because the architecture depends on the URL fragment.
That makes browser routing part of the cryptographic protocol.
- Old database rows have different semantics
The migration warning in the README is easy to overlook, but it's essential.
- The deployment configuration doesn't automatically solve the migration
The project deploys a Docker image to Cloud Run and configures database credentials through environment variables.
Infrastructure deployment and data migration are separate operations.
A successful container deployment doesn't mean the database is compatible with the new encryption format.
Before vs. after
| Before| After
Encryption location| Server| Browser
Cipher| AES-CBC| AES-256-GCM
Key model| Fixed/shared key| Fresh key per share
Server receives plaintext| Yes| No
Server decrypts content| Yes| No
Database stores| Plaintext after server decryption| Ciphertext
Key in HTTP request| Server-controlled| No
Key in share URL| No| URL fragment
Authentication of ciphertext| No| AES-GCM authentication
The historical PR confirms the architectural transition from server-side payload decryption to client-side encryption and decryption.
Validation: what I would test
A cryptographic refactor isn't finished when one happy-path share works.
The important regression cases are:
Normal share
"hello world"
↓
encrypt
↓
store
↓
retrieve
↓
decrypt
↓
"hello world"
Unicode
Test:
こんにちは 🔐 नमस्ते
The encryption layer works on UTF-8 encoded text, so Unicode should survive the round trip.
Tampered ciphertext
Change one character in the stored ciphertext.
Expected result:
AES-GCM decryption fails
The application should not display modified plaintext.
Wrong key
Change the key in the URL fragment.
Expected result:
decryption fails
Password-protected share
Wrong password should fail before the ciphertext is returned to the browser.
Correct password should return the encrypted payload and allow local decryption.
Expired share
An expired link should be rejected by the backend before content is returned.
Legacy database row
This is the migration test that matters most.
A row created before the E2EE change must not accidentally be interpreted as AES-GCM ciphertext.
The repository explicitly warns that those rows need migration or deletion.
One thing I would improve next
There is one lesson here that goes beyond this particular encryption change:
make the data contract explicit.
Right now, the database's "content" column doesn't tell us whether a row contains old plaintext or new ciphertext.
A future version could make the format explicit with something like:
content_format = "AES_GCM_V1"
or a versioned envelope:
{
"version": 1,
"algorithm": "AES-256-GCM",
"payload": "..."
}
That would make future migrations much safer.
It also makes observability much easier.
If a decryption error occurs, we can distinguish:
invalid ciphertext
wrong key
legacy plaintext
corrupt payload
unsupported encryption version
instead of treating all of them as "something went wrong."
Where Sentry fits
One important disclosure: ShareText's public repository does not currently contain a Sentry integration, so I wouldn't claim that Sentry discovered or diagnosed this change.
But this is exactly the kind of boundary where error observability becomes valuable.
For example, the browser currently catches decryption failures here:
try {
...
readonlyContent.textContent =
await decryptContent(data.content, contentKey);
} catch (err) {
showError('Access Error', err.message);
}
In a production application, that is an excellent place for structured error reporting.
I would report the failure category — but never the plaintext or encryption key.
Useful diagnostic context could include:
share ID
encryption version
browser/platform
ciphertext length
operation = decrypt
error category
while deliberately excluding:
plaintext
password
AES key
full URL
That gives an observability system enough information to answer:
«"Are users suddenly unable to decrypt links?"»
without turning the telemetry system into another place where secrets can leak.
What I'm proud of
The biggest improvement wasn't replacing AES-CBC with AES-GCM.
It was recognizing that the server shouldn't possess the secret required to decrypt the data in the first place.
Once that clicked, the architecture became much cleaner:
Browser owns the key.
Server owns the ciphertext.
Database stores the ciphertext.
URL fragment carries the key.
Each component has one job.
And the cryptographic boundary is enforceable rather than merely documented.
What I learned
The most dangerous bugs aren't always syntax errors or exceptions.
Sometimes the code is doing exactly what it was written to do — but the security model behind the code is wrong.
The backend can happily save a "content" field.
The database can happily return a "content" field.
The API can happily return a "content" field.
None of those facts tell you whether the system is actually keeping plaintext away from the server.
You have to follow the data.
From the text box.
Across the browser.
Into the HTTP request.
Through the controller.
Into the service.
Into the database.
And back again.
That end-to-end trace exposed the real boundary.
Final takeaway
The hardest part of this bug wasn't cryptography.
It was changing the meaning of a piece of data without breaking every layer that touches it.
The final design is simple:
«Encrypt before the network.
Store only ciphertext.
Keep the key out of the request.
Decrypt only at the destination.»
That is the kind of bug I like most: the fix isn't a clever one-line condition.
It's the moment when the entire system finally agrees on what the data is supposed to mean.
Repository and implementation references
- "ShareText repository" (https://github.com/ajaym0urya/ShareText)
- "E2EE implementation commit "e41f95c"" (https://github.com/ajaym0urya/ShareText/commit/e41f95c)
- "Client encryption/decryption" (https://github.com/ajaym0urya/ShareText/blob/main/src/main/resources/static/app.js)
- "Shared text service" (https://github.com/ajaym0urya/ShareText/blob/main/src/main/java/com/sharetext/service/SharedTextService.java)
Suggested tags
"#bugsmash" "#sentry" "#security" "#webdev" "#java" "#javascript" "#springboot" "#encryption"
Suggested cover image
A dark, minimal diagram showing a browser on the left, a locked ciphertext/database in the middle, and a second browser on the right. A glowing key should travel only through the URL fragment, while the server/database are visibly unable to access it.
Top comments (0)