A lightweight, reusable Elixir library for RSA-signed entitlement token validation.

Xentitlement provides a minimal cryptographic layer that validates JSON tokens signed with RSA-SHA256. It's designed to be shared across multiple applications that need to verify signed authorization tokens without coupling to a specific service's authentication system.

Features

  • RSA-SHA256 signatures — Asymmetric cryptography for secure token verification
  • Canonical JSON — Deterministic serialization prevents tampering
  • Stateless validation — No state, configuration, or database required
  • Zero dependencies (except Jason for JSON parsing)
  • 100% test coverage — Comprehensive test suite included

Installation

Add to your project's mix.exs:

def deps do
  [
    {:xentitlement, path: "../xentitlement"}
  ]
end

Then run mix deps.get.

Quick Start

Create a Token (with Private Key)

alias Xentitlement

claims = %{
  "entitlement_id" => "967376a7-6a33-4bc8-846c-ca443c5c56eb",
  "user_id" => "user-123",
  "operation" => "file_upload",
  "expires_at" => "2026-12-31"
}

{:ok, token_json} = Xentitlement.sign_token(claims, private_key_pem)
# Send token_json to client (e.g., in response header or body)

Validate a Token (with Public Key)

alias Xentitlement

# Validate a token from an HTTP header
case Xentitlement.extract_from_header(conn) do
  {:ok, token} ->
    case Xentitlement.validate(token, public_key_pem, entitlement_id) do
      {:ok, claims} ->
        # Authorization granted; use claims
        handle_authorized(claims)
      {:error, reason} ->
        # Validation failed
        reject_request(reason)
    end
  {:error, :missing} ->
    reject_request("no_token")
end

API

Sign Operations (Private Key)

Xentitlement.sign_token(claims, private_key_pem)

Signs a claims map and returns a JSON string with RSA signature.

Parameters:

  • claims (map) — Authorization data (keys should be strings)
  • private_key_pem (binary) — RSA private key in PEM format

Returns:

  • {:ok, token_json} — Signed token as JSON string with signature field
  • {:error, :invalid_key} — Private key could not be decoded
  • {:error, :signing_failed} — Signature operation failed

Xentitlement.sign_canonical_json(canonical_json, private_key_pem)

Signs canonical JSON with RSA private key (low-level).

Parameters:

  • canonical_json (binary) — JSON string to sign
  • private_key_pem (binary) — RSA private key in PEM format

Returns:

  • {:ok, signature} — Base64-encoded RSA signature
  • {:error, :invalid_key} — Key could not be decoded

Validate Operations (Public Key)

Xentitlement.validate(token_json, public_key_pem, entitlement_id)

Validates an entitlement token.

Parameters:

  • token_json (binary) — JSON string containing signed token
  • public_key_pem (binary) — RSA public key in PEM format
  • entitlement_id (binary) — Expected entitlement ID (UUID)

Returns:

  • {:ok, claims} — Token is valid; contains all fields from the token
  • {:error, :invalid_json} — Token is not valid JSON
  • {:error, :invalid_signature} — RSA signature verification failed
  • {:error, :invalid_claims} — Missing or mismatched entitlement_id, or invalid signature field

Xentitlement.extract_from_header(conn, header_name \\ "x-entitlement")

Extracts entitlement token from HTTP header.

Parameters:

  • conn (Plug.Conn) — HTTP connection
  • header_name (binary, optional) — HTTP header name (default: "x-entitlement")

Returns:

  • {:ok, token} — Token extracted
  • {:error, :missing} — Header not present
  • {:error, :multiple} — Multiple headers present

Token Format

An entitlement token is a JSON object:

{
  "entitlement_id": "967376a7-6a33-4bc8-846c-ca443c5c56eb",
  "user_id": "user-123",
  "operation": "file_upload",
  "expires_at": "2026-12-31",
  "signature": "base64-encoded-rsa-sha256-signature",
  "custom_field_1": "value1",
  "custom_field_2": 42
}

Signature Computation

The signature is computed over the canonical JSON representation:

  1. Remove the signature field
  2. Sort all keys alphabetically
  3. Compact JSON encoding (no whitespace)
  4. RSA-SHA256 sign the result
  5. Base64-encode the signature bytes

This ensures that any tampering with the token is detected.

Generating RSA Keys

Generate Keys with OpenSSL

Generate a 2048-bit RSA keypair:

# Generate private key
openssl genrsa -out private_key.pem 2048

# Extract public key from private key
openssl rsa -in private_key.pem -pubout -out public_key.pem

Using Keys in Elixir

Load keys from files:

defmodule MyApp.Entitlements do
  @doc "Load RSA keys from files"
  def load_keys do
    %{
      private_key: File.read!("config/keys/private_key.pem"),
      public_key: File.read!("config/keys/public_key.pem")
    }
  end

  @doc "Or load from environment variables (recommended for production)"
  def load_keys_from_env do
    %{
      private_key: System.fetch_env!("ENTITLEMENT_PRIVATE_KEY"),
      public_key: System.fetch_env!("ENTITLEMENT_PUBLIC_KEY")
    }
  end
end

Storing Keys Securely

Production Best Practices:

  1. Never commit private keys to version control
  2. Use environment variables (e.g., ENTITLEMENT_PRIVATE_KEY)
  3. Use a key management service (e.g., AWS KMS, HashiCorp Vault)
  4. Restrict file permissions on key files (chmod 600)
  5. Rotate keys regularly according to your security policy

Key Format Requirements

Keys must be in PEM format (Privacy Enhanced Mail):

# Private key format
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
...
-----END RSA PRIVATE KEY-----

# Public key format  
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
...
-----END PUBLIC KEY-----

Testing

Generate tokens for testing:

alias Xentitlement.Test.EntitlementHelpers

keys = EntitlementHelpers.load_test_keys()

token_json = EntitlementHelpers.generate_valid_entitlement_json(
  entitlement_id: "967376a7-6a33-4bc8-846c-ca443c5c56eb",
  user_id: "user-123",
  operation: "file_upload",
  expires_at: "2026-12-31",
  private_key: keys.private_key
)

{:ok, claims} = Xentitlement.validate(token_json, keys.public_key, entitlement_id)

Architecture

xentitlement/
 lib/
    xentitlement.ex                    # Public API (delegates)
    xentitlement/entitlements.ex       # Core validation logic
 test/
    xentitlement_test.exs              # Public API tests
    xentitlement/entitlements_test.exs # Validation logic tests
    support/entitlement_helpers.ex     # Test fixtures
    fixtures/*.pem                     # RSA test keys
 CLAUDE.md                              # Development guide

Usage in Services

Signing Service (Generates Tokens)

The signing service has the RSA private key and generates signed tokens:

def generate_presigned_url_token(user_id, file_name) do
  claims = %{
    "entitlement_id" => entitlement_id(),
    "user_id" => user_id,
    "operation" => "file_upload",
    "file_name" => file_name,
    "expires_at" => Date.utc_today() |> Date.add(1) |> Date.to_iso8601()
  }

  signature = sign_claims(claims)  # Sign with private key
  
  claims
  |> Map.put("signature", signature)
  |> Jason.encode!()
end

Validating Service (Verifies Tokens)

The validating service has the RSA public key and verifies tokens:

def verify_presigned_url(token_json) do
  Xentitlement.validate(token_json, public_key_pem(), entitlement_id())
end

Design Notes

Why RSA instead of HMAC?

  • RSA allows verification without sharing a secret key
  • Multiple services can verify tokens with only the public key
  • No need for a central key distribution system

Why canonical JSON?

  • Ensures the signature was computed over a deterministic representation
  • Prevents ambiguity in JSON key ordering or whitespace
  • Simplifies debugging and testing

Why stateless?

  • No database, cache, or session required for validation
  • Xentitlement can run anywhere: web servers, workers, CLI tools
  • Each service manages its own public keys independently

Development

See CLAUDE.md for detailed development guidelines, testing requirements, and architecture decisions.

Run Tests

mix test                  # Run all tests
mix test --cover          # Generate coverage report

Coverage should be 100% (or very close — see CLAUDE.md for exceptions).

Code Quality

mix format               # Format code
mix compile --warnings-as-errors  # Compile with strict checks

License

BSD-3-Clause License. See LICENSE for details.

Questions?

See CLAUDE.md for detailed FAQ and troubleshooting.