PhoenixKit.Utils.SessionFingerprint (phoenix_kit v2.60.2)

Copy Markdown View Source

Session fingerprinting utilities for preventing session hijacking.

This module provides functions to create and verify session fingerprints based on IP address and user agent data. These fingerprints help detect when a session token is being used from a different location or device than where it was created.

Security Considerations

  • IP addresses can change (mobile users, VPNs, etc.), so strict enforcement may impact legitimate users
  • User agents can be spoofed, but provide an additional layer of verification
  • This is defense-in-depth: fingerprinting complements, not replaces, other security measures

Configuration

You can configure the strictness level in your application config:

config :phoenix_kit,
  session_fingerprint_enabled: true,
  session_fingerprint_strict: false  # true = force re-auth, false = log warnings

Examples

# Create a fingerprint from a connection
fingerprint = SessionFingerprint.create_fingerprint(conn)

# Verify a fingerprint
case SessionFingerprint.verify_fingerprint(conn, stored_ip, stored_ua_hash) do
  :ok -> # Fingerprint matches
  {:warning, :ip_mismatch} -> # IP changed, but might be legitimate
  {:warning, :user_agent_mismatch} -> # User agent changed
  {:error, :fingerprint_mismatch} -> # Both changed, likely hijacked
end

Summary

Functions

Creates a session fingerprint from a Plug.Conn connection.

Checks if session fingerprinting is enabled in the application config.

Extracts the IP address from a connection.

Extracts and hashes the user agent from a connection.

The visitor's address to move a session to when the one it was stored with was a reverse proxy's — or nil when the stored address stands.

Checks if strict fingerprint verification is enabled.

Verifies a session fingerprint against the current connection.

Types

t()

@type t() :: %PhoenixKit.Utils.SessionFingerprint{
  ip_address: String.t(),
  user_agent_hash: String.t()
}

Functions

create_fingerprint(conn)

Creates a session fingerprint from a Plug.Conn connection.

Returns a %SessionFingerprint{} struct with :ip_address and :user_agent_hash fields.

Examples

iex> create_fingerprint(conn)
%SessionFingerprint{ip_address: "192.168.1.1", user_agent_hash: "a1b2c3d4..."}

fingerprinting_enabled?()

Checks if session fingerprinting is enabled in the application config.

Examples

iex> fingerprinting_enabled?()
true

get_ip_address(conn)

Extracts the IP address from a connection.

Delegates to PhoenixKit.Utils.IpAddress.extract_from_conn/1: a forwarded header is trusted only when conn.remote_ip is itself a loopback or private address (a proxy on the same box or network), and only the LAST x-forwarded-for entry — the one a proxy appends, not the one a client sent — is used. The previous implementation trusted x-forwarded-for unconditionally and took the FIRST entry, so anyone could spoof this fingerprint's IP (and therefore the "new device" / session-hijack checks built on it) with a plain client-supplied header, proxy or no proxy.

Examples

iex> get_ip_address(conn)
"192.168.1.1"

hash_user_agent(conn)

Extracts and hashes the user agent from a connection.

Returns a SHA256 hash of the user agent string for privacy and storage efficiency.

Examples

iex> hash_user_agent(conn)
"a1b2c3d4e5f6..."

proxy_rebind_address(conn, stored_ip, stored_ua_hash)

@spec proxy_rebind_address(Plug.Conn.t(), String.t() | nil, String.t() | nil) ::
  String.t() | nil

The visitor's address to move a session to when the one it was stored with was a reverse proxy's — or nil when the stored address stands.

Before core could read the visitor's address through a proxy that writes ip:port (Caddy's {remote}), a session signed in behind one stored the proxy's own loopback or private address. Read correctly now, every request of such a session would be a changed IP: a warning each time, a sign-out under strict mode. A request that came through a proxy (its peer is loopback/private), from the same browser, for a session stored with a loopback/private address and now read as a public one, is that session — it moves to the current address once and is checked as usual from then on.

This is a deliberate, one-off loosening, not a defence: a user agent is easy to copy, so a stolen token with the browser's user agent, sent through the proxy, is moved too. What bounds it: a session stored with a private/loopback address, used again through a private peer (a proxy) from a public address, moves once — after it, the stored address is public and never moves again. That includes a LAN client that signed in through the same proxy (its forwarded address private too). A request straight from a public peer and a different browser are not moved.

strict_mode?()

Checks if strict fingerprint verification is enabled.

When strict mode is enabled, fingerprint mismatches will force re-authentication. When disabled, mismatches only log warnings.

Examples

iex> strict_mode?()
false

verify_fingerprint(conn, stored_ip, stored_ua_hash, opts \\ [])

Verifies a session fingerprint against the current connection.

Returns:

  • :ok if fingerprint matches
  • {:warning, :ip_mismatch} if only IP changed
  • {:warning, :user_agent_mismatch} if only user agent changed
  • {:error, :fingerprint_mismatch} if both changed
  • :ok if stored fingerprint is nil (backward compatibility)

Examples

iex> verify_fingerprint(conn, "192.168.1.1", "abc123")
:ok

iex> verify_fingerprint(conn, "10.0.0.1", "abc123")
{:warning, :ip_mismatch}