September 4, 2026·7 min read

From Static IV to Dynamic IV: Improving Local Storage Encryption on Android

#Android#Kotlin#Security
Towfiqu barbhuiya on Unsplash

Sometimes, security improvements don’t come from something planned in a sprint.

In my case, it started with a pentest report that came in outside the scope of our ongoing sprint.

One of the findings was related to how our Android app encrypted data stored in local storage.

Some application data needs to be stored locally using mechanisms such as SharedPreferences or other encrypted local storage solutions.

The data was already encrypted.

But as we learned from this finding, using encryption doesn’t necessarily mean the implementation is secure.

One of the concerns was an older encryption implementation that was still using a static Initialization Vector (IV).

That finding made us take another look at an encryption mechanism that had been running in the app for quite some time.

The Existing Implementation: Static IV

Our existing implementation was already using AES to encrypt data before storing it locally.

The encryption mode was:

AES/GCM/NoPadding

There is nothing inherently wrong with AES-GCM.

GCM is an authenticated encryption mode. Besides protecting the confidentiality of the plaintext, it also produces an authentication tag that can be used to detect whether the encrypted data has been modified.

The problem was how we were using it.

Part of the old implementation was still using a static Initialization Vector.

At a high level, the encryption process looked something like this:

Plaintext + Secret Key + Initialization Vector (IV)
   ↓
AES-GCM
   ↓
Ciphertext + Authentication Tag

The IV is a value used together with the encryption key during the encryption process.

In the old implementation, the IV came from a predefined value.

Conceptually, it was something like:

IV = always_the_same_value

This meant that multiple encryption operations could end up using the same key and IV combination.

And with AES-GCM, that’s where the problem starts.

Why Is a Static IV a Problem?

AES-GCM has one very important requirement:

“An IV, or nonce, must not be reused with the same encryption key.”

So the real issue wasn’t simply that the IV was hardcoded.

The more important problem was nonce reuse.

Imagine we have one encryption key:

Key A

And every encryption operation uses the same IV:

Encryption #1
Key A + IV X + Data A
Encryption #2
Key A + IV X + Data B
Encryption #3
Key A + IV X + Data C

Even though the plaintext is different, the Key A + IV X combination keeps getting reused.

With AES-GCM, reusing a nonce with the same key can break the security guarantees that GCM is supposed to provide.

So using a strong encryption algorithm alone isn’t enough.

How we use that algorithm matters just as much.

After discussing the finding with the team, we decided to remove the static IV and replace it with a dynamic IV.

Introducing Dynamic IV

The idea is actually pretty simple.

Instead of using the same IV for every encryption operation, we generate a new IV every time we encrypt something.

Instead of this:

Key A + IV X
Key A + IV X
Key A + IV X

we now have:

Key A + Random IV 1
Key A + Random IV 2
Key A + Random IV 3

This way, each encryption operation gets a different nonce.

To generate the IV, we can use SecureRandom.

private const val IV_LENGTH = 12
val iv = ByteArray(IV_LENGTH)
SecureRandom().nextBytes(iv)

That brings up another question:

Why 12 bytes?

Why a 12-Byte Dynamic IV?

12 bytes is equal to:

12 bytes = 96 bits

For AES-GCM, a 96-bit IV is the recommended and most commonly used size.

A 96-bit IV also gets special handling in the GCM construction, which is why it has become the standard choice for AES-GCM nonces.

So every time the app performs an encryption operation, the flow becomes:

SecureRandom
     ↓
Generate 12-byte IV
     ↓
AES-GCM Encryption

A simplified implementation looks like this:

private const val IV_LENGTH = 12
private const val TAG_LENGTH = 128
fun encrypt(secretKey: SecretKey, plainText: ByteArray): ByteArray {
    val iv = ByteArray(IV_LENGTH)
    SecureRandom().nextBytes(iv)
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val parameterSpec = GCMParameterSpec(TAG_LENGTH, iv)
    cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec)
    val cipherText = cipher.doFinal(plainText)
    return iv + cipherText
}

The important part is surprisingly small:

val iv = ByteArray(12)
SecureRandom().nextBytes(iv)

The IV no longer comes from a constant that gets reused.

Every encryption operation gets a fresh IV generated using a cryptographically secure random generator.

Why SecureRandom?

If all we need is 12 bytes that should be different each time, a reasonable question is:

Why use **SecureRandom** instead of a regular random generator?

Because in a cryptographic context, we need a source of randomness designed specifically for security-sensitive operations.

SecureRandom provides a cryptographically strong random number generator in Java and Android.

For values such as:

IV
Nonce
Salt
Cryptographic random values

we should use a cryptographically secure random generator rather than one intended for things like simulations, game logic, or random UI behavior.

For a dynamic IV, we want enough randomness that generating the same IV for different encryption operations is extremely unlikely.

If the IV Changes Every Time, How Do We Decrypt the Data?

Moving to a dynamic IV introduces an obvious question.

Decryption requires the same IV that was used during encryption.

If we generate a new IV every time, we need to keep it somewhere.

Fortunately:

The IV is not a secret.

Unlike the encryption key, the IV doesn’t need to be hidden.

This means we can store the IV together with the ciphertext.

For example, our encrypted payload can be structured like this:

[ IV ][ Ciphertext + Authentication Tag ]

With a 12-byte IV:

| 12-byte IV | Encrypted Data + GCM Tag |

The entire payload can then be Base64-encoded before being stored in local storage.

The complete flow looks like this:

Plaintext
    ↓
Generate Random 12-byte IV
    ↓
AES-GCM Encrypt
    ↓
IV + Ciphertext + Authentication Tag
    ↓
Base64
    ↓
Local Storage

With this approach, each encrypted value carries the IV required to decrypt it later

The Decryption Process

Decryption is basically the reverse process.

First, decode the Base64 value.

Then take the first 12 bytes as the IV.

The remaining bytes contain the ciphertext and the authentication tag produced by GCM.

Local Storage
    ↓
Base64 Decode
    ↓
Extract first 12 bytes as IV
    ↓
Remaining bytes as Ciphertext + Tag
    ↓
AES-GCM Decrypt
    ↓
Plaintext

A simplified implementation looks like this:

fun decrypt(secretKey: SecretKey, encryptedData: ByteArray): ByteArray {
    val iv = encryptedData.copyOfRange(0, IV_LENGTH)
    val cipherText = encryptedData.copyOfRange(IV_LENGTH, encryptedData.size)
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val parameterSpec = GCMParameterSpec(TAG_LENGTH, iv)
    cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec)
    return cipher.doFinal(cipherText)
}

With this format, we don’t need separate storage just for the IV.

The encrypted payload already contains the non-secret information required for decryption.

What Actually Changed?

From a code perspective, moving from a static IV to a dynamic IV looks like a relatively small change.

Before:

Static IV

After:

SecureRandom → 12-byte IV

But from a cryptographic design perspective, the difference is significant.

Before:

Key A + IV X
Key A + IV X
Key A + IV X
Key A + IV X

After:

Key A + Random IV 1
Key A + Random IV 2
Key A + Random IV 3
Key A + Random IV 4

Each encryption operation gets a different nonce.

This avoids the static nonce behavior that previously allowed the same key and IV combination to be reused.

For AES-GCM, nonce uniqueness is an important part of its security requirements.

Can a Random 12-Byte IV Collide?

Theoretically, yes.

When using randomly generated IVs, there is no mathematical guarantee that two encryption operations will never produce the same value.

But the space available with a 96-bit IV is huge:

2^96

When generated using SecureRandom, the probability of a collision is extremely small for a reasonable number of encryption operations in a typical mobile application.

There is still an important detail here.

As more data is encrypted using the same key, the probability of a collision gradually increases because of the birthday bound.

So using a random dynamic IV doesn’t mean:

“Generate 12 random bytes and we’re safe forever.”

For systems performing a very large number of encryption operations, nonce generation strategy, the number of operations performed under the same key, and key rotation should still be considered as part of the overall cryptographic design.

For local storage in a typical Android application, however, a random 96-bit IV generated using SecureRandom is a practical approach.

Why Was This Solution Accepted by the Pentester?

What we changed wasn’t simply:

Static IV

to:

12-byte IV

The important change was how the IV was generated and used.

Previously:

Key A + Static IV
        ↓
     AES-GCM
        ↓
    Ciphertext

The same IV could be reused multiple times with the same encryption key.

After the change:

              SecureRandom
                    │
                    ↓
              12-byte IV
                    │
                    ↓
Plaintext ─────→ AES-GCM
                    │
                    ↓
          IV + Ciphertext + Tag
                    │
                    ↓
              Local Storage

Every encryption operation gets a fresh IV.

The implementation no longer relies on a static IV and follows the nonce requirements of AES-GCM more closely.

After the implementation was reviewed and tested again, this approach was accepted by the pentester for the static IV finding.

Lessons Learned

One thing stood out to me from this experience.

It’s easy to think:

“The data is encrypted with AES-GCM, so it should be secure.”

But it’s not quite that simple.

We can use:

AES/GCM/NoPadding

which is a strong encryption mode.

But if we use the IV incorrectly, we can still undermine the security guarantees the algorithm is supposed to provide.

A few things I took away from this:

  • Don’t use a static IV with AES-GCM.
  • Don’t reuse the same key and IV combination.
  • Generate a fresh IV for every encryption operation.
  • Use a 96-bit, or 12-byte, IV for AES-GCM.
  • Use a cryptographically secure random generator such as SecureRandom.
  • An IV is not a secret, so it can be stored alongside the ciphertext.
  • Make sure the IV used during encryption is available again during decryption.
  • Don’t just look at the algorithm. Pay attention to how it’s being used.

Because in the end:

Cryptography is not only about which algorithm we use, but also about how we use it.

And sometimes, a pentest finding that initially looks like just another ad-hoc task becomes a good reason to revisit old assumptions in the codebase and improve a design we previously thought was secure enough.