Signed token generation and verification.
Sigra uses two token strategies:
Signed tokens -- for session cookies and transport. Created via
Plug.Crypto.sign/4using the host app'ssecret_key_basewith per-purpose salts ("sigra-session-token","sigra-email-token", etc.).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
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 (fromendpoint.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 toPlug.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
Generates a random token and its SHA-256 hash for database storage.
Returns {raw_token, hashed_token} where:
raw_tokenis a URL-safe base64-encoded string (sent to the user)hashed_tokenis 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
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.
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
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
@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 basepurpose- The purpose string used when generating the tokentoken- The token to verifyopts- Options passed toPlug.Crypto.verify/4(e.g.,max_age:)
Examples
iex> {:ok, user_id} = Sigra.Token.verify(secret, "sigra-session-token", token, max_age: 86400)
@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.