September 13, 2026·5 min read

From One Encryption Scheme to Many: Designing a Versioned Encryption Layer on Android

#Android#Kotlin#Security#Architecture
Gabriela on Unsplash

Encryption often starts as a small implementation detail.

One helper. One algorithm. One interceptor.

A request needs to be encrypted, so you encrypt the body before sending it to the backend.

Simple enough.

The problem starts when the system evolves.

Some endpoints still depend on the old encryption flow. Newer endpoints use a different scheme. Certain requests need a different key source. Others should not be encrypted at all.

At that point, the problem is no longer just:

How do we encrypt this request?

It becomes:

How do we support multiple encryption flows without turning the networking layer into a collection of conditionals?

That was the point where I started thinking about encryption as a versioned layer instead of a single helper.

When One Encryption Flow Is No Longer Enough

With only one encryption scheme, the flow is easy to understand:

Request
   ↓
Encrypt Body
   ↓
Send Request

The interceptor can simply read the request body, encrypt it, rebuild the request, and continue.

Then a new encryption mechanism arrives.

The old one cannot be removed yet because existing APIs still depend on it.

So now the application has something like:

Old APIs      → Legacy Encryption
New APIs      → Encryption V1
Newer APIs    → Encryption V2

The first solution is usually straightforward:

val encryptedBody = when {
    useV2 -> encryptV2(body)
    useV1 -> encryptV1(body)
    else -> encryptLegacy(body)
}

This works.

Then more requirements appear.

Some endpoints should skip encryption.

Some need a different key exchange mechanism.

Some versions require an additional header.

Before long, the interceptor starts accumulating conditions.

when {
    noEncryption -> ...
    useDh && useV2 -> ...
    useDh && useV1 -> ...
    useLegacy -> ...
    else -> ...
}

The code may still be correct, but it becomes harder to reason about.

The question I started asking was:

If V3 is introduced tomorrow, how many places do I need to change?

If the answer is “too many,” the issue is probably not the cryptography itself.

It is the structure around it.

Make the Version Explicit

The first improvement was simple: represent the encryption version as one explicit state.

Instead of several booleans:

isLegacy
isV1
isV2

use an enum:

enum class CryptoVersion {
    LEGACY,
    V1,
    V2
}

This removes ambiguous states.

With booleans, this is technically possible:

isLegacy = true
isV1 = true

But what does that actually mean?

A request should use one encryption version at a time.

With an enum, the intent becomes much clearer:

val cryptoVersion = CryptoVersion.V2

It is a small change, but it gives the rest of the design a much better foundation.

The Interceptor Should Not Know Everything

The bigger issue was responsibility.

An interceptor can easily end up doing too much:

  • deciding which encryption version to use,
  • selecting a key,
  • choosing an encryption implementation,
  • encrypting the payload,
  • adding headers,
  • handling migration rules,
  • deciding what happens when encryption fails.

That is a lot of knowledge in one place.

The distinction that helped me was separating two questions:

Which encryption should this request use?

and:

How should that encryption be performed?

Those are related, but they are not the same responsibility.

The networking layer should mostly handle the first one.

The encryption layer should handle the second.

A simple abstraction is enough:

interface EncryptionStrategy {
    fun encrypt(plainText: String): String
    fun decrypt(cipherText: String): String
}

Each encryption version can then own its implementation.

class V2EncryptionStrategy : EncryptionStrategy {
    override fun encrypt(plainText: String): String {
        return V2Encryption.encrypt(plainText)
    }
    override fun decrypt(cipherText: String): String {
        return V2Encryption.decrypt(cipherText)
    }
}

The important part is not the interface itself.

The important part is that the interceptor no longer needs to know how V2 works internally.

It just needs the correct strategy.

Resolve the Strategy in One Place

Once the versions are explicit, the next step is centralizing the mapping between version and implementation.

class EncryptionResolver(
    private val legacy: EncryptionStrategy,
    private val v1: EncryptionStrategy,
    private val v2: EncryptionStrategy
) {
    fun resolve(version: CryptoVersion): EncryptionStrategy {
        return when (version) {
            CryptoVersion.LEGACY -> legacy
            CryptoVersion.V1 -> v1
            CryptoVersion.V2 -> v2
        }
    }
}

The flow becomes:

Request
   ↓
Resolve CryptoVersion
   ↓
EncryptionResolver
   ↓
EncryptionStrategy
   ↓
Encrypted Request

The interceptor coordinates the process.

The strategy handles the crypto implementation.

That separation alone makes the flow easier to understand.

Let the Endpoint Declare What It Needs

The next question is where the encryption version should come from.

One option is to infer it from the URL.

That works, but it couples security behavior to naming conventions in the backend.

I prefer making the requirement explicit at the API level.

For example:

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Crypto(
    val version: CryptoVersion
)

Then an endpoint can declare its requirement directly:

@Crypto(CryptoVersion.V2)
@POST("transactions")
suspend fun createTransaction(
    @Body request: TransactionRequest
): TransactionResponse

An older endpoint can stay on the legacy flow:

@Crypto(CryptoVersion.LEGACY)
@POST("legacy/transactions")
suspend fun createLegacyTransaction(
    @Body request: TransactionRequest
): TransactionResponse

I like this approach because the encryption requirement is visible where the endpoint is defined.

Another developer does not need to open the interceptor just to understand how the request is handled.

Retrofit’s Invocation tag makes it possible to read this annotation inside OkHttp:

val invocation = request.tag(Invocation::class.java)
val method = invocation?.method()
val cryptoAnnotation = method?.getAnnotation(Crypto::class.java)
val cryptoVersion = cryptoAnnotation?.version ?: CryptoVersion.LEGACY

Now the decision flow is explicit:

Retrofit API
     ↓
@Crypto(V2)
     ↓
OkHttp Request
     ↓
CryptoVersion.V2
     ↓
EncryptionResolver
     ↓
V2EncryptionStrategy

Keep Version-Specific Metadata Close to the Version

Different encryption versions may also require different request headers.

For example:

LEGACY → no header
V1     → X-Crypto-Version: 1
V2     → X-Crypto-Version: 2

Instead of scattering this logic across the interceptor, it can live close to CryptoVersion.

fun CryptoVersion.headerValue(): String? =
    when (this) {
        CryptoVersion.LEGACY -> null
        CryptoVersion.V1 -> "1"
        CryptoVersion.V2 -> "2"
    }

Then the interceptor simply applies it:

cryptoVersion.headerValue()
    ?.let { value ->
        requestBuilder.header(
            "X-Crypto-Version",
            value
        )
    }

This is a small detail, but it keeps version-specific behavior from leaking into multiple places.

A Boring Interceptor Is a Good Interceptor

After moving responsibilities out, the interceptor becomes much simpler.

Conceptually, it only needs to do this:

Inspect Request
      ↓
Resolve Version
      ↓
Resolve Strategy
      ↓
Encrypt
      ↓
Attach Metadata
      ↓
Proceed

That is exactly what I want from an interceptor.

It should be boring.

It should orchestrate the flow, not contain the entire encryption system.

The more crypto-specific knowledge that stays outside the interceptor, the easier the networking layer is to maintain.

The Real Benefit Is Migration

The biggest value of versioning is not cleaner code.

It is migration.

In a production application, replacing an encryption mechanism is rarely a one-step change.

Older endpoints may still depend on the previous scheme.

Older app versions may still be active.

The backend may only support the new encryption mechanism on certain APIs.

For some period of time, multiple generations need to coexist:

              CryptoVersion
                     │
          ┌──────────┼──────────┐
          │          │          │
       LEGACY       V1         V2
          │          │          │
      Old APIs   Migration   New APIs

That is not necessarily bad architecture.

Sometimes it is simply what a safe migration looks like.

A versioned layer lets the migration happen gradually instead of forcing every API to change at once.

The V3 Test

One question I like to use when evaluating this design is:

What happens when V3 arrives?

Ideally, the answer is boring.

Add the new version:

enum class CryptoVersion {
    LEGACY,
    V1,
    V2,
    V3
}

Add a new strategy.

Register it in the resolver.

Add version-specific metadata if necessary.

Existing implementations should not need to be rewritten.

That is the behavior I want from the architecture:

Adding a new version should extend the system, not rewrite the old one.

Be Careful With Fallbacks

There is one detail I think deserves extra attention: encryption failures.

This is tempting:

return runCatching {
    strategy.encrypt(body)
}.getOrElse {
    body
}

If encryption fails, send the original body.

The request still works.

But for a sensitive request, this can create a much worse failure mode:

Encryption Failed
       ↓
Fallback
       ↓
Plaintext Sent

In many cases, I would rather fail the request entirely.

Encryption Failed
       ↓
Request Failed

The correct behavior depends on the system, but the important part is that the decision should be explicit.

Plaintext fallback should never happen just because it is the easiest way to keep the request running.

Don’t Build This Before You Need It

I would not introduce all of these abstractions on day one.

If an application only has one encryption flow, no migration requirement, and no endpoint-specific behavior, then a resolver, multiple strategies, annotations, and versioning are probably unnecessary.

The extra structure starts becoming useful when the problem is real:

Multiple Encryption Schemes
            +
Backward Compatibility
            +
Different Endpoint Requirements
            +
Gradual Migration

That is when versioning begins to pay for itself.

Not because the pattern looks clean, but because the system has actually become more complex.

What I Learned

A few things stood out to me while working through this kind of design.

Make the encryption version explicit. If only one version can be active at a time, model it as one state.

Keep crypto implementation out of the interceptor. The interceptor should coordinate the flow, not become the encryption layer itself.

Put security requirements close to the API. It makes endpoint behavior easier to understand.

Treat backward compatibility as part of the design. Legacy encryption is not always something that can be removed immediately.

Fail safely. An encryption failure should not silently become a plaintext request.

Design for the next version. If V3 arrives, adding it should feel boring.

That is usually a good sign.

Final Thoughts

Encryption can start as a helper and slowly become part of the application architecture.

Once multiple versions, migration rules, and endpoint-specific behavior enter the picture, the challenge is no longer only about implementing the right algorithm.

It is about keeping the system understandable while it evolves.

For me, that is the real value of a versioned encryption layer.

Old endpoints can keep working.

New ones can adopt a newer scheme.

And the next version can be added without rewriting everything that came before it.

The goal is not to make encryption more sophisticated.

It is to keep its complexity contained.