End-to-End Encryption for APIs with X25519 and AES
Building secure APIs requires robust encryption to protect data in transit and ensure only intended recipients can access sensitive information. This post demonstrates how to implement end-to-end encryption for API communications using X25519 key exchange and AES-256-GCM authenticated encryption in Go.
Architecture Overview
The system uses X25519 key exchange with AES-256-GCM encryption for secure API communication:
- Key Discovery: Client fetches server’s public key from
/.well-known/encryption-keys
- Key Exchange: Client generates ephemeral key pair and sends public key via header
- Shared Secret: Both parties compute identical shared secret using ECDH
- Encrypted Communication: All requests/responses encrypted with AES-256-GCM

Core Cryptographic Components
X25519 Key Exchange Implementation
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"time"
"golang.org/x/crypto/curve25519"
)
type KeyPair struct {
PrivateKey [32]byte
PublicKey [32]byte
}
type ServerKey struct {
ID string `json:"id"`
PublicKey string `json:"public_key"`
}
type WellKnownKeys struct {
Keys []ServerKey `json:"keys"`
}
func generateKeyPair() (*KeyPair, error) {
var privateKey [32]byte
if _, err := rand.Read(privateKey[:]); err != nil {
return nil, err
}
var publicKey [32]byte
curve25519.ScalarBaseMult(&publicKey, &privateKey)
return &KeyPair{
PrivateKey: privateKey,
PublicKey: publicKey,
}, nil
}
func computeSharedSecret(privateKey, peerPublicKey [32]byte) ([32]byte, error) {
var sharedSecret [32]byte
curve25519.ScalarMult(&sharedSecret, &privateKey, &peerPublicKey)
// Use SHA256 to derive encryption key from shared secret
return sha256.Sum256(sharedSecret[:]), nil
}
func (kp *KeyPair) PublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(kp.PublicKey[:])
}
func (kp *KeyPair) ID() string {
return computeKeyFingerprint(kp.PublicKey)
}
func (kp *KeyPair) ToServerKey() ServerKey {
return ServerKey{
ID: kp.ID(),
PublicKey: kp.PublicKeyBase64(),
}
}
func publicKeyFromBase64(base64Str string) ([32]byte, error) {
var key [32]byte
decoded, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
return key, err
}
if len(decoded) != 32 {
return key, fmt.Errorf("invalid key length: expected 32 bytes, got %d", len(decoded))
}
copy(key[:], decoded)
return key, nil
}
func computeKeyFingerprint(publicKey [32]byte) string {
keyFingerprint := sha256.Sum256(publicKey[:])
return base64.URLEncoding.EncodeToString(keyFingerprint[:16]) // Use first 16 bytes of fingerprint
}
func verifyKeyID(publicKey [32]byte, keyID string) bool {
expectedID := computeKeyFingerprint(publicKey)
return expectedID == keyID
}
AES-256-GCM Encryption
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
func encryptBytes(plaintext []byte, key [32]byte) ([]byte, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
func decryptBytes(ciphertext []byte, key [32]byte) ([]byte, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(ciphertext) < gcm.NonceSize() {
return nil, fmt.Errorf("ciphertext too short")
}
nonce := ciphertext[:gcm.NonceSize()]
ciphertext = ciphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
API Server Implementation
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"github.com/gorilla/mux"
)
type Server struct {
keyPair *KeyPair
}
type SecureRequest struct {
Data string `json:"data"`
}
type SecureResponse struct {
Data string `json:"data"`
}
func NewServer() (*Server, error) {
keyPair, err := generateKeyPair()
if err != nil {
return nil, err
}
return &Server{
keyPair: keyPair,
}, nil
}
func (s *Server) wellKnownHandler(w http.ResponseWriter, r *http.Request) {
response := WellKnownKeys{
Keys: []ServerKey{s.keyPair.ToServerKey()},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (s *Server) computeSharedSecret(r *http.Request) ([32]byte, error) {
clientPublicKeyB64 := r.Header.Get("X-Client-Public-Key")
if clientPublicKeyB64 == "" {
return [32]byte{}, fmt.Errorf("missing X-Client-Public-Key header")
}
clientPublicKey, err := publicKeyFromBase64(clientPublicKeyB64)
if err != nil {
return [32]byte{}, fmt.Errorf("invalid client public key: %v", err)
}
return computeSharedSecret(s.keyPair.PrivateKey, clientPublicKey)
}
func (s *Server) secureHandler(w http.ResponseWriter, r *http.Request) {
var req SecureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
sharedSecret, err := s.computeSharedSecret(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decrypt request
encryptedData, err := base64.StdEncoding.DecodeString(req.Data)
if err != nil {
http.Error(w, "Invalid data encoding", http.StatusBadRequest)
return
}
decryptedData, err := decryptBytes(encryptedData, sharedSecret)
if err != nil {
http.Error(w, "Decryption failed", http.StatusBadRequest)
return
}
// Process request (echo back)
responseData := fmt.Sprintf("Echo: %s", string(decryptedData))
// Encrypt response
encryptedResponse, err := encryptBytes([]byte(responseData), sharedSecret)
if err != nil {
http.Error(w, "Encryption failed", http.StatusInternalServerError)
return
}
response := SecureResponse{
Data: base64.StdEncoding.EncodeToString(encryptedResponse),
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Encrypted", "e2ee")
json.NewEncoder(w).Encode(response)
}
func (s *Server) Start(addr string) error {
router := mux.NewRouter()
router.HandleFunc("/.well-known/encryption-keys", s.wellKnownHandler).Methods("GET")
router.HandleFunc("/api/v1/secure", s.secureHandler).Methods("POST")
return http.ListenAndServe(addr, router)
}
API Client Implementation
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
type Client struct {
baseURL string
clientKeys *KeyPair
serverKey *ServerKey
httpClient *http.Client
}
func NewClient(baseURL string) (*Client, error) {
clientKeys, err := generateKeyPair()
if err != nil {
return nil, err
}
return &Client{
baseURL: baseURL,
clientKeys: clientKeys,
httpClient: &http.Client{},
}, nil
}
func (c *Client) fetchServerKey() error {
resp, err := c.httpClient.Get(c.baseURL + "/.well-known/encryption-keys")
if err != nil {
return fmt.Errorf("failed to fetch server keys: %v", err)
}
defer resp.Body.Close()
var wellKnownKeys WellKnownKeys
if err := json.NewDecoder(resp.Body).Decode(&wellKnownKeys); err != nil {
return fmt.Errorf("failed to decode server keys: %v", err)
}
if len(wellKnownKeys.Keys) == 0 {
return fmt.Errorf("server has no available keys")
}
// Use first available key
c.serverKey = &wellKnownKeys.Keys[0]
return nil
}
func (c *Client) computeSharedSecret() ([32]byte, error) {
if c.serverKey == nil {
return [32]byte{}, fmt.Errorf("no server key available")
}
serverPublicKey, err := publicKeyFromBase64(c.serverKey.PublicKey)
if err != nil {
return [32]byte{}, fmt.Errorf("invalid server public key: %v", err)
}
return computeSharedSecret(c.clientKeys.PrivateKey, serverPublicKey)
}
func (c *Client) SendMessage(message string) (string, error) {
// Fetch server key if not already done
if c.serverKey == nil {
if err := c.fetchServerKey(); err != nil {
return "", fmt.Errorf("failed to fetch server key: %v", err)
}
}
// Compute shared secret
sharedSecret, err := c.computeSharedSecret()
if err != nil {
return "", fmt.Errorf("failed to compute shared secret: %v", err)
}
// Encrypt the message
encryptedData, err := encryptBytes([]byte(message), sharedSecret)
if err != nil {
return "", err
}
secureReq := SecureRequest{
Data: base64.StdEncoding.EncodeToString(encryptedData),
}
reqBody, err := json.Marshal(secureReq)
if err != nil {
return "", err
}
// Create the request
req, err := http.NewRequest("POST", c.baseURL+"/api/v1/secure", bytes.NewBuffer(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Client-Public-Key", c.clientKeys.PublicKeyBase64())
req.Header.Set("X-Encrypted", "e2ee")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var secureResp SecureResponse
if err := json.NewDecoder(resp.Body).Decode(&secureResp); err != nil {
return "", err
}
// Decrypt response
responseData, err := base64.StdEncoding.DecodeString(secureResp.Data)
if err != nil {
return "", err
}
decryptedResponse, err := decryptBytes(responseData, sharedSecret)
if err != nil {
return "", err
}
return string(decryptedResponse), nil
}
Usage Example
package main
import (
"fmt"
"log"
"time"
)
func main() {
// Start the server
server, err := NewServer()
if err != nil {
log.Fatal("Failed to create server:", err)
}
go func() {
if err := server.Start(":8080"); err != nil {
log.Fatal("Server failed:", err)
}
}()
// Give server time to start
time.Sleep(time.Second)
// Create client
client, err := NewClient("http://localhost:8080")
if err != nil {
log.Fatal("Failed to create client:", err)
}
// Send encrypted message
response, err := client.SendMessage("Hello, secure world!")
if err != nil {
log.Fatal("Failed to send message:", err)
}
fmt.Printf("Server response: %s\n", response)
}
Benefits of Well-Known Key Discovery
The .well-known/encryption-keys
endpoint provides several advantages:
- Standards-based: Follows RFC 8615 and industry patterns (OAuth, OIDC)
- Simple discovery: Single endpoint for all server public keys
- Cacheable: Keys can be cached by clients, CDNs, and proxies
- Debugging-friendly: Easy to inspect keys via curl or browser
- Scalable: No server-side session state required
- Fingerprint verification: Key IDs prevent tampering and corruption
Example Well-Known Response
{
"keys": [
{
"id": "mv_LtU3vIRuL49Iu9q",
"public_key": "mvfLxb5D7yq4xdKe+Wajw8TU4fiLWryf1tOgx+ThFYpS"
}
]
}
Note: The id
field is the base64url-encoded SHA256 fingerprint of the public key (first 16 bytes), and the public_key
field uses standard base64 encoding.
This implementation provides a robust foundation for secure API communications with modern cryptographic standards, ensuring data confidentiality, integrity, and authenticity in transit.