KMS sessions are bearer credentials that let a caller use principal-scoped permissions without passing the principal password on every operation.
A session contains:
- a high-entropy bearer
secretreturned to the caller; - a persisted secret verifier stored as versioned HMAC-SHA256;
- encrypted private-key material needed for authorized operations;
- state:
pendingorauthenticated; - expiration controls for TTL, idle timeout, and absolute lifetime.
Trust model
Treat session secrets like passwords or API tokens.
- Send remote session secrets only over TLS or a trusted local network.
- Do not log session secrets, request bodies, APM payloads, crash dumps, or proxy bodies.
- Store session secrets only in memory or a secure client-side secret store.
- Use
refresh_session/2to rotate bearer secrets. - Use
nudge_session/2only to keep an active session from idling out.
Session types
Principal password sessions
Created by authenticating a principal with a password:
{:ok, session} = KMS.create_session("users", "alice", %{password: password})If the principal has no enabled factors, the session is immediately authenticated.
If the principal has enabled factors, the session starts as pending and includes a challenge. The caller must complete factors with advance_session/2.
Passphrase sessions
Passphrase sessions are created directly from a caller-supplied high-entropy passphrase:
{:ok, session} = KMS.create_passphrase_session(%{passphrase: secret})KMS rejects low-entropy passphrases at creation time. Human passphrases like open sesame are not accepted.
Passphrase sessions have no principal_id. They can encrypt/decrypt only session-protected aliases with an envelope wrapped for that passphrase, or create a new passphrase-protected alias. They cannot access RMK-only aliases, manage tags, create principals, or share aliases onward. The passphrase session itself is not an alias grant.
Use Factor-bound aliases for the complete principal sharing and anonymous passphrase replacement/revocation workflow. Revocation removes an envelope, so existing sessions also lose that access.
Session states
pending
Pending sessions have not completed all required factors.
Pending sessions:
- are not active for authorization;
- can be advanced with
advance_session/2; - expire according to session lifetime controls;
- track failed factor attempts.
authenticated
Authenticated sessions can be used for authorized operations.
Example:
{:ok, cleartext} = KMS.decrypt_alias(ciphertext, "my-key", session: session.secret)A session is active only when:
- state is
authenticated; expires_atis in the future;idle_expires_atis in the future;absolute_expires_atis in the future.
Factor flow
Create session:
{:ok, session} = KMS.create_session("users", "alice", %{password: password})If pending:
{:ok, session} = KMS.advance_session(session.secret, %{code: code})Each completed factor advances the session. After all required factors complete, the session becomes authenticated.
The built-in one_time_code provider returns the code in the challenge response. This is for development and testing only.
For production MFA, KMS includes a built-in totp provider compatible with authenticator apps such as Google Authenticator, 1Password, Authy, and similar RFC 6238 applications. TOTP is still widely used and acceptable for many deployments, but it is phishable. WebAuthn/passkeys are stronger when available.
Bearer secret storage
KMS never stores the raw session secret in the database.
New sessions store:
hmac-sha256$v1$<base64-hmac>The HMAC key is configured with:
config :kms, :session_hash_key, System.fetch_env!("KMS_SESSION_HASH_KEY")Production startup requires KMS_SESSION_HASH_KEY to be high entropy.
Legacy unsalted SHA-256 verifiers are still accepted so old sessions can expire naturally.
Lifetime controls
KMS has three session lifetime controls:
config :kms, :session_default_ttl_ms, :timer.hours(1)
config :kms, :session_max_ttl_ms, :timer.hours(1)
config :kms, :session_idle_timeout_ms, :timer.hours(1)
config :kms, :session_max_lifetime_ms, :timer.hours(12)Fields:
idle_expires_at: idle timeout deadline.absolute_expires_at: hard lifetime deadline from session creation.expires_at: effective expiry, the earliest of idle and absolute deadlines.
Caller-provided ttl_ms is capped by session_max_ttl_ms, session_idle_timeout_ms, and session_max_lifetime_ms.
After absolute_expires_at, forced re-authentication is required. Refresh/nudge cannot extend past absolute lifetime.
Refreshing sessions
Refresh rotates the session bearer secret:
{:ok, refreshed} = KMS.refresh_session(session, ttl_ms: :timer.minutes(30))Refresh behavior:
- requires an active authenticated session;
- decrypts private-key material with the old secret;
- generates a new bearer secret;
- stores a new HMAC verifier;
- re-encrypts private-key material with the new secret;
- invalidates the old secret;
- extends lifetime subject to configured caps.
Remote HTTP route:
POST /api/sessions/refreshNudging sessions
Nudge extends idle expiry without rotating the bearer secret:
{:ok, nudged} = KMS.nudge_session(session.secret, ttl_ms: :timer.minutes(30))Nudge behavior:
- requires an active authenticated session;
- extends idle/effective expiry subject to caps;
- does not decrypt private-key material;
- does not rotate the bearer secret.
Remote HTTP route:
POST /api/sessions/nudgeUse nudge for activity pings. Use refresh when the client can safely receive and store a new bearer secret.
Password attempt rate limits
KMS enforces fixed-window failed password attempt limits by default.
Default config:
config :kms, :session_login_rate_limit,
principal: [limit: 5, window_ms: :timer.minutes(5)],
source: [limit: 50, window_ms: :timer.minutes(5)]Scopes:
principal: failed attempts per{kind, ref}.source: failed attempts per remote source.
Remote HTTP session creation supplies source from the connection remote IP.
Library callers can supply a source manually:
KMS.create_session("users", "alice", %{password: password}, source: "203.0.113.10")Use source for the request origin identifier your application trusts: client IP, proxy-real IP, tenant/client id, device id, or mTLS certificate fingerprint.
Successful password authentication clears the principal counter. Source counters remain until the window expires to resist username spraying.
Disable built-in enforcement if your application provides its own controls:
config :kms, :session_login_rate_limit, falseTOTP authenticator factors
Create enrollment data:
{:ok, enrollment} =
KMS.Principals.Factors.TOTP.enrollment("KMS", "alice@example.com")Render enrollment.otpauth_uri as a QR code, ask the user for the first code, then confirm it:
:ok = KMS.Principals.Factors.TOTP.verify_enrollment(enrollment.secret, user_code)Store the factor after confirmation:
{:ok, factor} =
KMS.create_principal_factor("users", "alice", %{
provider: "totp",
label: "authenticator",
position: 1,
config: enrollment.config
})The factor config stores the TOTP seed under "encrypted_secret" after RMK encryption. The TOTP challenge returned during session creation includes metadata only and never includes the code or seed.
During authentication:
{:ok, pending} = KMS.create_session("users", "alice", %{password: password})
{:ok, session} = KMS.advance_session(pending.secret, %{code: "123456"})Default TOTP settings:
- 6 digits
- 30-second period
- SHA-1 HMAC, per RFC 6238 / authenticator app convention
- +/- 1 time-step verification window
TOTP security notes:
- TOTP is shared-secret based; protect enrollment secrets.
- TOTP is phishable; prefer WebAuthn/passkeys for phishing-resistant MFA when possible.
- Keep factor and login rate limits enabled.
- Use TLS and never log codes or request bodies.
Factor attempt controls
Factor attempts also have controls:
config :kms, :session_factor_attempt_limit, 5
config :kms, :session_factor_rate_limit, [limit: 5, window_ms: :timer.minutes(5)]session_factor_attempt_limit caps failed factor attempts per pending session.
session_factor_rate_limit is optional fixed-window throttling for factor attempts. If unset/invalid, only the attempt-count limit applies.
Deleting sessions
Delete one session:
{:ok, session} = KMS.delete_session(session.secret)Delete sessions for a principal:
{:ok, count} = KMS.delete_sessions("users", "alice")Expired sessions are removed by cleanup code. expires_at is the effective expiry used for cleanup.
Remote HTTP API
All non-health HTTP routes require the configured admin bearer token.
Session routes:
POST /api/sessions/create
POST /api/sessions/passphrase
POST /api/sessions/advance
POST /api/sessions/active
POST /api/sessions/list
POST /api/sessions/refresh
POST /api/sessions/nudge
POST /api/sessions/delete
POST /api/sessions/delete_allRemote JSON session responses include metadata and the bearer secret only when needed by the caller. Remote session handles redact secret and challenge from inspect/1 and JSON encoding.
Local library API
Common functions:
KMS.create_session(kind, ref, attrs)
KMS.create_session(kind, ref, attrs, opts)
KMS.create_passphrase_session(attrs)
KMS.advance_session(secret, attrs)
KMS.fetch_session(secret)
KMS.fetch_active_session(secret)
KMS.refresh_session(session, opts)
KMS.nudge_session(secret, opts)
KMS.delete_session(secret)
KMS.delete_sessions(kind, ref)Use session: on authorization-sensitive operations:
KMS.encrypt_alias("clear", "alias", session: session.secret)
KMS.decrypt_alias(ciphertext, "alias", session: session.secret)Calls without session: are trusted local/admin calls for RMK-only aliases; session-protected aliases still require a usable recipient session.
Operational notes
- Set
KMS_SESSION_HASH_KEYin production. - Set TLS or trusted TLS termination for remote HTTP.
- Disable request-body logging in proxies, app logs, APM tools, and error reporters.
- Disable Erlang crash dump writing in production with
ERL_CRASH_DUMP_BYTES=0orERL_CRASH_DUMP_SECONDS=0. - Treat any leaked session secret as active credential compromise.
- Rotate/refresh sessions after suspected exposure.