Sigra.Token (Sigra v1.20.0)

Copy Markdown View Source

Signed token generation and verification.

Sigra uses two token strategies:

  1. Signed tokens -- for session cookies and transport. Created via Plug.Crypto.sign/4 using the host app's secret_key_base with per-purpose salts ("sigra-session-token", "sigra-email-token", etc.).

  2. Hashed tokens -- for email confirmation, password reset, and API keys. A random token is generated, the SHA-256 hash is stored in the database, and the raw token is sent to the user. Verification compares the hash of the submitted token against the stored hash.

All token comparisons use constant-time comparison via Plug.Crypto.secure_compare/2 to prevent timing attacks.

Summary

Functions

Generates a signed token for the given purpose and data.

Generates a random token and its SHA-256 hash for database storage.

Generates a signed invitation envelope binding email into the HMAC payload.

Hashes a raw token with SHA-256 for storage comparison.

Performs a constant-time comparison of two strings.

Verifies a signed token and extracts the embedded data.

Verifies an invitation envelope and returns the raw token, bound email, and hashed-token-for-DB-lookup.

Functions

generate(secret_key_base, purpose, data, opts \\ [])

(since 0.1.0)
@spec generate(String.t(), String.t(), term(), keyword()) :: binary()

Generates a signed token for the given purpose and data.

Uses Plug.Crypto.sign/4 with a purpose-specific salt derived from the host app's secret_key_base.

Parameters

  • secret_key_base - The host app's secret key base (from endpoint.config)
  • purpose - A string identifying the token's purpose (e.g., "sigra-session-token")
  • data - The data to embed in the token (typically a user ID)
  • opts - Options passed to Plug.Crypto.sign/4 (e.g., max_age:, key_iterations:)

Examples

iex> token = Sigra.Token.generate(secret, "sigra-session-token", user_id)
iex> is_binary(token)
true

generate_hashed_token()

(since 0.1.0)
@spec generate_hashed_token() :: {String.t(), binary()}

Generates a random token and its SHA-256 hash for database storage.

Returns {raw_token, hashed_token} where:

  • raw_token is a URL-safe base64-encoded string (sent to the user)
  • hashed_token is a 32-byte SHA-256 binary (stored in the database)

Examples

iex> {raw, hashed} = Sigra.Token.generate_hashed_token()
iex> is_binary(raw) and byte_size(hashed) == 32
true

generate_invite_envelope(secret_key_base, email)

(since 0.4.0)
@spec generate_invite_envelope(String.t(), String.t()) :: {String.t(), binary()}

Generates a signed invitation envelope binding email into the HMAC payload.

Returns {encoded_signed_token, hashed_token_for_storage}.

Why this diverges from sigra-confirm-token

Confirmation tokens sign the raw token only — the holder of the link IS the user being confirmed, so identity is bound by convention at DB compare time. Invitations are the exception: the holder of the link is NOT yet the authenticated principal, so identity must be bound cryptographically. This closes the Jetstream #907 / Keycloak CVE-2026-1529 class of invite-hijack bugs by construction.

Payload shape uses STRING keys ("t", "e") to avoid atom-table growth on decode.

hash_token(raw_token)

(since 0.1.0)
@spec hash_token(binary()) :: binary()

Hashes a raw token with SHA-256 for storage comparison.

Examples

iex> hashed = Sigra.Token.hash_token("some-raw-token")
iex> byte_size(hashed) == 32
true

secure_compare(left, right)

(since 0.1.0)
@spec secure_compare(binary(), binary()) :: boolean()

Performs a constant-time comparison of two strings.

Delegates to Plug.Crypto.secure_compare/2 to prevent timing attacks.

Examples

iex> Sigra.Token.secure_compare("abc", "abc")
true

iex> Sigra.Token.secure_compare("abc", "def")
false

verify(secret_key_base, purpose, token, opts \\ [])

(since 0.1.0)
@spec verify(String.t(), String.t(), binary(), keyword()) ::
  {:ok, term()} | {:error, :invalid | :expired}

Verifies a signed token and extracts the embedded data.

Returns {:ok, data} if the token is valid and not expired, or {:error, :invalid} / {:error, :expired} on failure.

Parameters

  • secret_key_base - The host app's secret key base
  • purpose - The purpose string used when generating the token
  • token - The token to verify
  • opts - Options passed to Plug.Crypto.verify/4 (e.g., max_age:)

Examples

iex> {:ok, user_id} = Sigra.Token.verify(secret, "sigra-session-token", token, max_age: 86400)

verify_invite_envelope(secret_key_base, encoded, max_age_seconds)

(since 0.4.0)
@spec verify_invite_envelope(String.t(), String.t(), pos_integer()) ::
  {:ok, %{raw_token: binary(), bound_email: String.t(), hashed_token: binary()}}
  | {:error, :invalid | :expired}

Verifies an invitation envelope and returns the raw token, bound email, and hashed-token-for-DB-lookup.

Fails {:error, :invalid} if HMAC verify fails, base64 decode fails, or the payload shape is wrong. Fails {:error, :expired} if the envelope is older than max_age_seconds.

Distinguishing :invalid from other errors MUST NOT leak information to the attacker — callers should treat both :invalid and :expired as "invitation link is not valid" with the same user-facing copy.