---
title: Spring MVC / Spring Bootの@PathVariableでFormat Preserving Encryptionする
summary: この記事では、Spring MVC の @PathVariable で AES‑FFX/FF3 を用いた FPE を実装し、公開用 ID を内部連番に自動変換する方法を紹介します。
tags: ["Spring Boot", "Spring MVC", "FPE", "Java", "Cipher"]
categories: ["Programming", "Java", "org", "springframework", "core", "convert", "converter"]
date: 2026-02-04T15:52:27.926Z
updated: 2026-02-04T15:52:27.925Z
---

Xで次のポストを見かけ、面白そうだったので試してみました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">カジュアルに連番避けたいだけなら、内部は連番にして性能稼ぎつつ、外部に見せるのは共通鍵でaes-ecb変換した値にすればいい。値を64bitに抑えたいならaes-ffx使えばいい <a href="https://t.co/ERkWf8HYdh">https://t.co/ERkWf8HYdh</a></p>&mdash; Kazuho Oku (@kazuho) <a href="https://twitter.com/kazuho/status/2017203486006591589?ref_src=twsrc%5Etfw">January 30, 2026</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

AES-FFXは、Format-Preserving Encryption（形式保持暗号化、FPE）の一種で、暗号化後もデータの形式（フォーマット）を保持する暗号化方式です。  
例えば、10桁の数字を暗号化しても、暗号化後も10桁の数字として出力されます。

元々の話は、データベースの主キーに使うのは UUID と連番のどちらが良いかという話題でした。UUID（v7）や ULID はソートされていますが、空間効率を考えると連番に軍配が上がります。ID を連番にすると、URL に公開する際に次の ID が推測されやすいという懸念があります。

主キーと公開用のキーを別々のカラムに持つという方法もありますが、FPE などを使えば、カラムを増やす必要なく、内部用の ID を推測しづらい形で公開できるというのが元ポストの趣旨かと思われます。

いずれにせよ、Spring MVC で実装しようとすると次のようなコードをまずは考えるのではないでしょうか？

```java
@GetMapping(path = "/customers/{customerId}")
public Customer getCustomer(@PathVariable("customerId") Long publicId) {
  Long customerId = convert(publicId);
  // ...
}
```

これでも間違ってはいないのですが、Long ↔ Long を都度手動で変換していると、変換漏れが発生したり、どちらの Long を使っているか混乱が起こると思われます。外に見せる ID は Spring MVC に入ってきた段階で内部 ID に変換され、アプリケーションコード上は内部 ID のみを扱うのが安全なので、この変換は Spring MVC 側に任せたいと思います。

以下では Java のライブラリ (https://github.com/mysto/java-fpe) があり、実装が容易な FF3-1 アルゴリズムを使用します。

> [!WARNING]
> NIST の 2025 年 2 月のドラフト 2 で、FF3 は NIST 標準から撤回されました。  
> 他のアルゴリズムを使う場合でも今回の記事と同じ方法が使えます。    

次の dependency を追加します。

```xml
        <dependency>
            <groupId>io.github.mysto</groupId>
            <artifactId>ff3</artifactId>
            <version>1.2.0</version>
        </dependency>
```

FF3 用の Cipher を Bean 定義する JavaConfig を次のように作ります。ここでは`key`と`tweak`がハードコードされていますが、実際にはプロパティなど、外部から取得するようにしてください。

```java
import com.privacylogistics.FF3Cipher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class CipherConfig {

    @Bean
    FF3Cipher ff3Cipher() {
        return new FF3Cipher("2DE79D232DF5585D68CE47882AE256D6", "CBD09280979564");
    }
}
```

Spring MVC では URL のパスパラメータの`String`から`@PathVariable`で指定したオブジェクトに変換する際に`org.springframework.core.convert.converter.Converter`が使用されます。この`Converter`内で公開用の ID から内部用の ID に変換すれば良いです。`String`から`Long`の`Converter`を作ってしまうと、影響範囲が大きくなってしまうので、ここでは ID 用のクラス(`CustomerId`)を作り、`String`から`CustomerId`の`Converter`を作ることにします。

> [!TIP]
> 1つの`String`を引数にもつコンストラクタや、`valueOf(String)`、`of(String)`、`from(String)`の static ファクトリメソッドを持つクラスへの変換は`Converter`を実装しなくても自動で変換されます。

次のような Controller を作ります。

```java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.atomic.AtomicLong;

@RestController
public class CustomerController {
    private static final Logger logger = LoggerFactory.getLogger(CustomerController.class);
    private final AtomicLong counter = new AtomicLong(0);

    @PostMapping(path = "/customers")
    public Customer createCustomer() {
        return new Customer(new CustomerId(counter.incrementAndGet()));
    }

    @GetMapping(path = "/customers/{customerId}")
    public Customer getCustomer(@PathVariable CustomerId customerId) {
        Customer customer = new Customer(customerId);
        logger.info("Customer ID: {}", customer.id());
        return customer;
    }
}
```

顧客を新規作成する度に、ID が連番で払い出されます。

`CustomerId` は次の実装です。10 桁の数値として FF3 で暗号化することとします。

```java
import com.privacylogistics.FF3Cipher;

public record CustomerId(long value) {

    public long encrypt(FF3Cipher cipher) {
        String formatted = String.format("%010d", value);
        try {
            String encrypted = cipher.encrypt(formatted);
            return Long.parseLong(encrypted);
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }

    public static CustomerId decrypt(FF3Cipher cipher, String encrypted) {
        try {
            return new CustomerId(Integer.parseInt(cipher.decrypt(encrypted)));
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }
}
```

`Customer` の実装はシンプルです。

```java
public record Customer(CustomerId id) {
}
```

そして、`Converter` の実装です。

```java
import com.privacylogistics.FF3Cipher;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

@Component
public class CustomerIdConverter implements Converter<String, CustomerId> {
    private final FF3Cipher cipher;

    public CustomerIdConverter(FF3Cipher cipher) {
        this.cipher = cipher;
    }

    @Override
    public CustomerId convert(String source) {
        return CustomerId.decrypt(this.cipher, source);
    }
}
```

Spring Boot では `Converter` 実装クラスを Bean 定義すれば、自動で変換処理に追加されます。

これで `@PathVariable` で復号済みの内部 ID を直接受け取ることができます。

ついでに、この `Customer` クラスを JSON シリアライズ/デシリアライズする際にも、FF3 で暗号化・復号されるようにシリアライザとデシリアライザも用意します。次のコードは Jackson 3 を使った例です。

```java
import com.privacylogistics.FF3Cipher;
import org.springframework.boot.jackson.JacksonComponent;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;

@JacksonComponent
public class CustomerIdSerializer extends ValueSerializer<CustomerId> {
    private final FF3Cipher cipher;

    public CustomerIdSerializer(FF3Cipher cipher) {
        this.cipher = cipher;
    }

    @Override
    public void serialize(CustomerId value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException {
        gen.writeNumber(value.encrypt(cipher));
    }
}
```

```java
import com.privacylogistics.FF3Cipher;
import org.springframework.boot.jackson.JacksonComponent;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;

@JacksonComponent
public class CustomerIdDeserializer extends ValueDeserializer<CustomerId> {
    private final FF3Cipher cipher;

    public CustomerIdDeserializer(FF3Cipher cipher) {
        this.cipher = cipher;
    }

    @Override
    public CustomerId deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
        return CustomerId.decrypt(this.cipher, p.readValueAs(String.class));
    }
}
```

`@JacksonComponent` アノテーションを使うと、自動で Jackson の Module に登録されます。

これで準備ができたので、アプリケーションを起動し、数回リクエストを送ってみます。

```bash
curl -s http://localhost:8080/customers -XPOST
curl -s http://localhost:8080/customers -XPOST
curl -s http://localhost:8080/customers -XPOST
```

次のレスポンスが返ります。

```json
{"id":6413069952}
{"id":5812783830}
{"id":1827763417}
```

ID は 1 から連番で発行されるはずですが、レスポンスには期待通り、ぱっと見連番だとはわからない数値が含まれています。

では、この ID を使って情報を取得します。

```bash
curl -s http://localhost:8080/customers/6413069952
curl -s http://localhost:8080/customers/5812783830
curl -s http://localhost:8080/customers/1827763417
```

レスポンスは期待通り、パスパラメータと同じ ID が含まれるでしょう。

```json
{"id":6413069952}
{"id":5812783830}
{"id":1827763417}
```

ログを確認すると、

```
 Customer ID: CustomerId[value=1]
 Customer ID: CustomerId[value=2]
 Customer ID: CustomerId[value=3]
```

と出力されているので、内部では連番 ID として扱われていることがわかります。

---

`@PathVariable` でFPEを使い、公開用のIDから内部用のIDへ自動変換する方法を紹介しました。

> [!NOTE]
> 撤回されたFF 3ではなく、FF 1を使いたい場合は[こちらのライブラリ](https://sourceforge.net/p/format-preserving-encryption/code/ci/master/tree/src/main/java/org/fpe4j/)が参考になりそうですが、Maven Repositorsegmentに公開されていないようです。
