Xentitlement

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

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:

Returns:

Xentitlement.sign_canonical_json(canonical_json, private_key_pem)

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

Parameters:

Returns:

Validate Operations (Public Key)

Xentitlement.validate(token_json, public_key_pem, entitlement_id)

Validates an entitlement token.

Parameters:

Returns:

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

Extracts entitlement token from HTTP header.

Parameters:

Returns:

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?

Why canonical JSON?

Why stateless?

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.