Blog

December 5, 2024 · Technical · 9 min read

Four layers of cryptography for API security

Authentication tokens are only one layer of API security. Code and configuration for the others: TLS, signed JWTs and hashed API keys, request signing, field-level encryption and hash-chained audit logs.

More than authentication

Most API security guides focus on authentication (OAuth, API keys, JWTs), which matters but is only one layer. A secure API applies cryptographic protection at every level: transport (protecting data in transit), authentication (verifying identity), payload (integrity and confidentiality) and audit (detection and forensics).

Layer 1: transport security

TLS configuration

Every API must use TLS, but not all TLS configurations are equal:

# Modern TLS configuration
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;

# HSTS with long duration
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# Certificate transparency
ssl_ct on;
ssl_ct_static_scts /etc/nginx/scts;

Certificate management

Automate certificate renewal to prevent outages:

# Certbot with automatic renewal
certbot certonly --webroot -w /var/www/html -d api.example.com
echo "0 0 * * * root certbot renew --quiet" >> /etc/crontab

Mutual TLS between services

For internal APIs, require client certificates:

ssl_client_certificate /etc/nginx/ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;

Layer 2: authentication

Signing and verifying JWTs

JWTs are everywhere and often misconfigured:

use opencrypt::signature::{Ed25519, Signer, Verifier};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Claims {
    sub: String,      // Subject (user ID)
    aud: String,      // Audience (API identifier)
    exp: u64,         // Expiration (Unix timestamp)
    iat: u64,         // Issued at
    jti: String,      // JWT ID (for revocation)
    scope: Vec<String>, // Permissions
}

fn create_jwt(claims: &Claims, signing_key: &Ed25519SigningKey) -> String {
    let header = base64url_encode(r#"{"alg":"EdDSA","typ":"JWT"}"#);
    let payload = base64url_encode(&serde_json::to_string(claims).unwrap());

    let message = format!("{}.{}", header, payload);
    let signature = signing_key.sign(message.as_bytes());

    format!("{}.{}", message, base64url_encode(&signature))
}

fn verify_jwt(token: &str, verifying_key: &Ed25519VerifyingKey) -> Result<Claims, Error> {
    let parts: Vec<&str> = token.split('.').collect();
    if parts.len() != 3 {
        return Err(Error::InvalidFormat);
    }

    let message = format!("{}.{}", parts[0], parts[1]);
    let signature = base64url_decode(parts[2])?;

    verifying_key.verify(message.as_bytes(), &signature)?;

    let claims: Claims = serde_json::from_slice(&base64url_decode(parts[1])?)?;

    // Validate expiration
    if claims.exp < current_unix_timestamp() {
        return Err(Error::Expired);
    }

    Ok(claims)
}

JWT rules:

  1. Use asymmetric algorithms (EdDSA, ES256), not symmetric ones (HS256)
  2. Always validate the exp, aud and iss claims
  3. Keep tokens short-lived (15 minutes for access tokens)
  4. Implement token revocation for logout and compromise

API keys

For machine-to-machine authentication:

use opencrypt::hash::Sha256;
use opencrypt::kdf::Hkdf;

// Generate API key with embedded metadata
fn generate_api_key(client_id: &str) -> (String, [u8; 32]) {
    let mut rng = secure_rng();
    let secret: [u8; 32] = rng.gen();

    // Derive key ID from secret (for lookup without storing secret)
    let key_id = Sha256::hash(&secret)[..16].to_vec();
    let key_id_hex = hex::encode(&key_id);

    // Format: prefix_keyid_secret
    let api_key = format!("sk_live_{}_{}", key_id_hex, hex::encode(&secret));

    // Store only the hash of the secret
    let secret_hash = Sha256::hash(&secret);

    (api_key, secret_hash)
}

// Verify API key
fn verify_api_key(api_key: &str) -> Result<ClientId, Error> {
    let parts: Vec<&str> = api_key.split('_').collect();
    if parts.len() != 4 || parts[0] != "sk" {
        return Err(Error::InvalidFormat);
    }

    let key_id = parts[2];
    let secret = hex::decode(parts[3])?;
    let secret_hash = Sha256::hash(&secret);

    // Constant-time comparison against stored hash
    let stored_hash = db.get_key_hash(key_id)?;
    if !constant_time_eq(&secret_hash, &stored_hash) {
        return Err(Error::InvalidKey);
    }

    db.get_client_id(key_id)
}

Layer 3: payload security

Request signing

Sign request bodies to prevent tampering:

use opencrypt::mac::HmacSha256;

fn sign_request(
    method: &str,
    path: &str,
    body: &[u8],
    timestamp: u64,
    secret: &[u8; 32],
) -> String {
    let message = format!(
        "{}\n{}\n{}\n{}",
        method,
        path,
        timestamp,
        Sha256::hash(body).to_hex()
    );

    let signature = HmacSha256::mac(secret, message.as_bytes());
    base64::encode(&signature)
}

fn verify_request_signature(
    method: &str,
    path: &str,
    body: &[u8],
    timestamp: u64,
    signature: &str,
    secret: &[u8; 32],
) -> Result<(), Error> {
    // Reject old requests (prevent replay)
    let now = current_unix_timestamp();
    if timestamp < now - 300 || timestamp > now + 60 {
        return Err(Error::TimestampOutOfRange);
    }

    let expected = sign_request(method, path, body, timestamp, secret);
    if !constant_time_eq(signature.as_bytes(), expected.as_bytes()) {
        return Err(Error::InvalidSignature);
    }

    Ok(())
}

Field-level encryption

Encrypt sensitive fields before transmission:

use opencrypt::aead::{AesGcm256, Aead};

#[derive(Serialize, Deserialize)]
struct PaymentRequest {
    order_id: String,
    amount: u64,
    currency: String,

    #[serde(with = "encrypted_field")]
    card_number: String,

    #[serde(with = "encrypted_field")]
    cvv: String,
}

mod encrypted_field {
    use super::*;

    pub fn serialize<S>(value: &str, serializer: S) -> Result<S::Ok, S::Error>
    where S: Serializer {
        let key = get_field_encryption_key();
        let nonce = generate_nonce();
        let ciphertext = AesGcm256::new(&key)
            .encrypt(&nonce, value.as_bytes(), &[])
            .map_err(serde::ser::Error::custom)?;

        let encoded = format!("{}:{}", hex::encode(&nonce), hex::encode(&ciphertext));
        serializer.serialize_str(&encoded)
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<String, D::Error>
    where D: Deserializer<'de> {
        let encoded = String::deserialize(deserializer)?;
        let parts: Vec<&str> = encoded.split(':').collect();

        let nonce = hex::decode(parts[0]).map_err(serde::de::Error::custom)?;
        let ciphertext = hex::decode(parts[1]).map_err(serde::de::Error::custom)?;

        let key = get_field_encryption_key();
        let plaintext = AesGcm256::new(&key)
            .decrypt(&nonce, &ciphertext, &[])
            .map_err(serde::de::Error::custom)?;

        String::from_utf8(plaintext).map_err(serde::de::Error::custom)
    }
}

Layer 4: audit and compliance

Cryptographic audit logs

Make the audit trail tamper-evident:

use opencrypt::hash::Sha256;

#[derive(Serialize)]
struct AuditEntry {
    timestamp: u64,
    action: String,
    actor: String,
    resource: String,
    details: serde_json::Value,
    previous_hash: String,
    hash: String,
}

impl AuditEntry {
    fn new(
        action: &str,
        actor: &str,
        resource: &str,
        details: serde_json::Value,
        previous_hash: &str,
    ) -> Self {
        let mut entry = Self {
            timestamp: current_unix_timestamp(),
            action: action.to_string(),
            actor: actor.to_string(),
            resource: resource.to_string(),
            details,
            previous_hash: previous_hash.to_string(),
            hash: String::new(),
        };

        // Hash all fields except hash itself
        let content = serde_json::to_string(&entry).unwrap();
        entry.hash = Sha256::hash(content.as_bytes()).to_hex();

        entry
    }
}

fn verify_audit_chain(entries: &[AuditEntry]) -> bool {
    for i in 1..entries.len() {
        // Verify hash chain
        if entries[i].previous_hash != entries[i-1].hash {
            return false;
        }

        // Verify entry hash
        let mut entry = entries[i].clone();
        entry.hash = String::new();
        let expected_hash = Sha256::hash(
            serde_json::to_string(&entry).unwrap().as_bytes()
        ).to_hex();

        if entries[i].hash != expected_hash {
            return false;
        }
    }
    true
}

Security checklist

Transport

  • TLS 1.2+ only, prefer TLS 1.3
  • Strong cipher suites (AEAD only)
  • HSTS enabled with long max-age
  • Certificate transparency
  • Automated certificate renewal

Authentication

  • Asymmetric JWT algorithms
  • Short token lifetimes
  • Token revocation mechanism
  • API keys hashed in storage
  • Rate limiting per client

Payload

  • Request signing for mutations
  • Field-level encryption for PII
  • Input validation before decryption
  • Size limits on encrypted fields

Audit

  • Hash-chained audit logs
  • Immutable log storage
  • Regular chain verification
  • Anomaly detection

Each layer covers different attacks, and together the four give defense in depth that can withstand sophisticated attacks. Start with TLS and authentication, and add payload security and audit logging as the API matures.

Questions about this post, or working on something similar? Book a meeting.