1.4.1

Security Features

Preview ecosystem guide: Grant is not part of the Amber 2.0.0-beta.2 core web-app release gate. Its package version, API, and platform support may change independently. Do not add a personal fork as a default dependency.

Grant provides built-in security features for protecting sensitive data, generating secure tokens, and creating tamper-proof URLs.

Encrypted Attributes

Store sensitive data encrypted at rest.

Basic Encryption

class User < Grant::Base
  column id : Int64, primary: true
  column email : String
  column ssn : String?
  column credit_card_number : String?

  # Encrypt these fields
  encrypts :ssn, :credit_card_number
end

# Usage is transparent
user = User.create!(
  email: "[email protected]",
  ssn: "123-45-6789"
)

user.ssn  # => "123-45-6789" (decrypted)
# In database: encrypted blob

Deterministic Encryption

Use deterministic encryption when you need to search encrypted fields.

class User < Grant::Base
  # Non-deterministic (more secure, cannot search)
  encrypts :ssn

  # Deterministic (searchable)
  encrypts :phone_number, deterministic: true
end

# Can search deterministic fields
User.where(phone_number: "+1-555-1234")  # Works

# Cannot search non-deterministic fields
User.where(ssn: "123-45-6789")  # Won't work

Configuration

# config/initializers/encryption.cr
Grant::Encryption.configure do |config|
  config.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"]
  config.key_derivation_salt = ENV["ENCRYPTION_KEY_DERIVATION_SALT"]
  config.deterministic_key = ENV["ENCRYPTION_DETERMINISTIC_KEY"]
end

# Generate keys
# crystal eval 'require "random"; puts Random::Secure.hex(32)'

Secure Tokens

Generate cryptographically secure tokens for authentication.

Basic Token Generation

class User < Grant::Base
  column id : Int64, primary: true
  column email : String
  column auth_token : String?

  has_secure_token :auth_token
end

user = User.create!(email: "[email protected]")
user.auth_token  # => "pX27zsMN2ViQKta1bGfLmVJE"

# Regenerate token
user.regenerate_auth_token

Token Options

class ApiKey < Grant::Base
  column id : Int64, primary: true
  column user_id : Int64
  column key : String?
  column secret : String?

  # Default: 24 characters, URL-safe base64
  has_secure_token :key

  # Custom length
  has_secure_token :secret, length: 32

  # Hex format
  has_secure_token :hex_key, length: 16, alphabet: :hex
end

Token Authentication

class ApplicationController < Amber::Controller::Base
  def authenticate_api_key
    token = request.headers["Authorization"]?
      .try(&.gsub("Bearer ", ""))

    unless token && ApiKey.find_by(key: token)
      halt!(401, "Invalid API key")
    end
  end
end

Signed IDs

Create tamper-proof, expiring identifiers for URLs.

Basic Signed IDs

class User < Grant::Base
  include Grant::SignedId

  column id : Int64, primary: true
  column email : String
end

user = User.find!(1)

# Generate signed ID
signed_id = user.signed_id
# => "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iL..."

# Find by signed ID
found = User.find_signed(signed_id)
# => User(id: 1, email: "[email protected]")

# Invalid/tampered ID returns nil
User.find_signed("tampered_id")  # => nil

Expiring Signed IDs

# Expires in 15 minutes
signed_id = user.signed_id(expires_in: 15.minutes)

# Expires at specific time
signed_id = user.signed_id(expires_at: 1.hour.from_now)

# Expired ID returns nil
User.find_signed(expired_signed_id)  # => nil

Scoped Signed IDs

# Scope to specific purpose
signed_id = user.signed_id(purpose: :password_reset)

# Must use same purpose to verify
User.find_signed(signed_id, purpose: :password_reset)  # Works
User.find_signed(signed_id, purpose: :email_confirm)   # => nil

Use Cases

class PasswordResetController < ApplicationController
  def create
    user = User.find_by!(email: params["email"])
    token = user.signed_id(
      expires_in: 15.minutes,
      purpose: :password_reset
    )

    PasswordResetMailer.send(user.email, token)
    redirect_to "/login", notice: "Check your email"
  end

  def update
    user = User.find_signed!(
      params["token"],
      purpose: :password_reset
    )

    user.update!(password: params["password"])
    redirect_to "/login", notice: "Password updated"
  rescue Grant::InvalidSignedId
    redirect_to "/forgot-password", alert: "Invalid or expired link"
  end
end

Token Generation (token_for)

Generate purpose-specific tokens that can include record state.

class User < Grant::Base
  include Grant::TokenFor

  column id : Int64, primary: true
  column email : String
  column password_salt : String

  # Token invalidates when password_salt changes
  generates_token_for :password_reset, expires_in: 15.minutes do
    password_salt
  end

  generates_token_for :email_confirmation, expires_in: 24.hours do
    email
  end
end

# Generate token
user = User.find!(1)
token = user.generate_token_for(:password_reset)

# Find by token
found = User.find_by_token_for(:password_reset, token)

# Token invalidates if password changes
user.update!(password_salt: SecureRandom.hex)
User.find_by_token_for(:password_reset, token)  # => nil

Data Normalization

Automatically normalize data before saving.

class User < Grant::Base
  column email : String
  column phone : String?
  column name : String

  # Normalize email
  normalizes :email, &.downcase.strip

  # Normalize name
  normalizes :name, &.strip.titleize

  # Normalize phone (remove non-digits)
  normalizes :phone do |phone|
    phone.gsub(/\D/, "")
  end
end

user = User.new(
  email: "  [email protected]  ",
  name: "alice smith",
  phone: "(555) 123-4567"
)

user.email  # => "[email protected]"
user.name   # => "Alice Smith"
user.phone  # => "5551234567"

Enum Attributes

Type-safe enumerated values.

class User < Grant::Base
  column id : Int64, primary: true
  column role : String

  enum Role
    Guest
    Member
    Admin
    SuperAdmin
  end

  enum_attribute role : Role = :member
end

user = User.new
user.role        # => Role::Member
user.member?     # => true
user.admin?      # => false

user.admin!      # Sets role to Admin
user.role        # => Role::Admin

# Scopes generated automatically
User.admin       # Users with admin role
User.member      # Users with member role

Best Practices

1. Protect Sensitive Data

class User < Grant::Base
  # Always encrypt PII
  encrypts :ssn, :tax_id, :bank_account

  # Deterministic only when searchable needed
  encrypts :phone_number, deterministic: true

  # Never log sensitive data
  @[JSON::Field(ignore: true)]
  column ssn : String?
end

2. Use Scoped Tokens

# Always scope tokens to purpose
signed_id = user.signed_id(purpose: :password_reset)

# Never use generic signed IDs for sensitive operations

3. Set Appropriate Expiration

# Short expiration for sensitive operations
password_reset_token = user.signed_id(
  expires_in: 15.minutes,
  purpose: :password_reset
)

# Longer for less sensitive
email_unsubscribe = user.signed_id(
  expires_in: 30.days,
  purpose: :unsubscribe
)

4. Rotate Encryption Keys

# Support key rotation
Grant::Encryption.configure do |config|
  config.primary_key = ENV["NEW_ENCRYPTION_KEY"]
  config.previous_keys = [ENV["OLD_ENCRYPTION_KEY"]]
end