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
- 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 signprivate_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 tokenpublic_key_pem(binary) — RSA public key in PEM formatentitlement_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 connectionheader_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:
- Remove the
signaturefield - Sort all keys alphabetically
- Compact JSON encoding (no whitespace)
- RSA-SHA256 sign the result
- 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:
- Never commit private keys to version control
- Use environment variables (e.g.,
ENTITLEMENT_PRIVATE_KEY) - Use a key management service (e.g., AWS KMS, HashiCorp Vault)
- Restrict file permissions on key files (chmod 600)
- 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.