Skip to content

No published default secret: read SESSION_SECRET, else a random key per process (P1-11) - #9

Merged
eliasjpr merged 5 commits into
masterfrom
claude/azu-crystal-framework-plan-phqt6e-session-secret
Oct 27, 2026
Merged

eliasjpr merged 5 commits into
masterfrom
claude/azu-crystal-framework-plan-phqt6e-session-secret

Conversation

@eliasjpr

@eliasjpr eliasjpr commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What

  • config.secret defaults to SESSION_SECRET. Without it: Session::InsecureSecretException where require_secure_secret holds, and a random key for this process (one warning) elsewhere. config.secret = nil goes back to that default. The random fallback is not memoised per configuration, so it is refused again as soon as require_secure_secret holds (e.g. CRYSTAL_ENV=production set later).
  • require_secure_secret, unless set explicitly, reads CRYSTAL_ENV (case-insensitive, trimmed) each time it is checked, instead of once when the shard loads. Production and staging, as before.
  • A secret shorter than 32 bytes raises InsecureSecretException with a clear message, instead of an ArgumentError from AES. This holds with use_kdf on too (the :production, :high_security and :clustered presets): the HMAC signature uses the raw secret (Message::Verifier), so PBKDF2 does not make a short secret safe.
  • Session.configure validates the secret when the configured store encrypts with it (Store#encrypts_with_secret?: CookieStore, or encrypt_redis_data), so a misconfigured production app fails at boot instead of answering every request, liveness probes included, with a 500.
  • Configuration#encryptor reuses its encryptor while the secret, digest and KDF settings are unchanged (one reference holding the settings tuple and the encryptor), so PBKDF2 (100k iterations) no longer runs on every encrypt and decrypt.
  • SessionHandler still validates the secret before loading when its store encrypts, as a backstop for handlers built without Session.configure: such an app fails each request with InsecureSecretException (a 500, logged by the server) instead of answering 200 without a working session. Stores that do not encrypt never read the secret and are unaffected.
  • Configuration::DEFAULT_SECRET stays (so code that names it still compiles) and is still refused where require_secure_secret holds; it is never used by default.
  • Docs, UPGRADE.md and examples/configuration_examples.cr drop every hard-coded fallback key, and snippets no longer read ENV["SESSION_SECRET"] / ENV.fetch("SESSION_SECRET") (a KeyError when unset): they rely on the default. The Azu page says how this differs from Azu::Env.secret (azu refuses the fallback in its strict_environments, production unless AZU_STRICT_ENVS widens it; session in production and staging).

Why (gate-2 audit P1-11)

An app that never set a secret signed its cookies with a key published in this repository, so anyone could forge a UserSession. The refusal covered only CRYSTAL_ENV exactly production/staging at load time: Production, or a CRYSTAL_ENV set after the shard was required, ran on the public key.

Behaviour change

  • In development without SESSION_SECRET, sessions now end when the process restarts (the key is random per process). Set SESSION_SECRET to keep them.
  • A configured secret under 32 bytes now raises, with or without use_kdf (with the :production preset, "", "a" and "abc" were accepted before), InsecureSecretException (before, encryption failed with an ArgumentError the handler swallowed, so such a setup never had working cookie sessions).
  • With a cookie store (or encrypt_redis_data) and no usable secret in production/staging, Session.configure raises at boot; a handler built without it fails requests with a 500 instead of a 200 with no session. Set the secret (or SESSION_SECRET) before, or inside, the Session.configure block that sets such a store.

Tests

  • spec/configuration_spec.cr (written first): the default is never the published secret and is the same for every configuration in a process; SESSION_SECRET is read; CRYSTAL_ENV=Production set after the configuration was built makes encryptor raise, also after the random fallback was already used; SESSION_SECRET=short and config.secret = "" raise InsecureSecretException ("at least 32 bytes"); SessionHandler with a cookie store and a short secret raises. The four follow-up examples failed before the change. Two existing specs used a 31-byte secret and now use 32 bytes.
  • Second round (written first, 7 failed and 2 did not compile against the previous head): the production preset with secrets of 0, 1, 3 and 31 bytes raises; encryptor is reused while settings are unchanged and rebuilt when the salt or secret changes; validate_secret_for! raises for a CookieStore and not for a MemoryStore; Session.configure raises at boot with encrypt_redis_data and a short secret, and under the production preset; it does not raise when the store does not encrypt. spec/encryptor_spec.cr still builds Message::Encryptor directly with short secrets and use_kdf (the class itself has no minimum; only the configuration enforces it).
  • crystal spec with a local Redis: 539 examples, 0 failures (Crystal 1.22.1). crystal tool format --check src spec scripts examples: clean. scripts/check_docs.cr (README, UPGRADE, docs): 157 snippets, 0 failed.

Semver: patch or minor at your choice; no minor/major release marker in the commits, so the release job makes a patch.


Generated by Claude Code

…cess (P1-11)

Without a configured secret, sessions were signed with a key published in
this repository, and the production refusal read CRYSTAL_ENV once when
the shard loaded, as an exact lowercase match.

- secret defaults to SESSION_SECRET, else a random key for this process
  with one warning; InsecureSecretException where require_secure_secret
  holds.
- require_secure_secret, unless set, reads CRYSTAL_ENV (case-insensitive)
  each time it is checked.
- Docs, UPGRADE and the example no longer show hard-coded fallback keys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 minor

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
CodeStyle 1 minor

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

…irst use raises)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
- `secret` no longer memoises the random fallback, so setting CRYSTAL_ENV
  to production after it was used is still refused.
- `validate_secret!` refuses a secret under 32 bytes (unless use_kdf) with
  InsecureSecretException, instead of an ArgumentError from AES that the
  handler logged and swallowed; SESSION_SECRET=short and secret = "" are
  covered.
- SessionHandler validates the secret before loading when its store
  encrypts (CookieStore, or encrypt_redis_data): a misconfigured production
  app now fails each request with InsecureSecretException (a 500) instead
  of answering 200 without a working session.
- Docs: no hard-coded fallback secrets left (quick start, installation,
  clustered Redis store); snippets no longer read ENV["SESSION_SECRET"] or
  ENV.fetch (a KeyError when unset), they rely on the default; the
  behaviour without a secret is described as it is; the Azu page says how
  Azu::Env.secret differs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
…e the encryptor

The HMAC signature uses the raw secret, so PBKDF2 does not make a short one
safe: the production preset accepted "", "a" and "abc". The 32-byte minimum
now holds whatever use_kdf says.

Session.configure validates the secret when the configured store encrypts
with it (CookieStore, or encrypt_redis_data), so a misconfigured app fails at
boot. SessionHandler keeps its per-request check as a backstop; both ask the
store (Store#encrypts_with_secret?).

Configuration#encryptor reuses its encryptor while the secret, digest and KDF
settings are unchanged, so PBKDF2 no longer runs on every encrypt and decrypt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
key-derivation.md no longer says PBKDF2 protects weak or short secrets, and
says when the key is derived. UPGRADE.md: the short-secret bullet no longer
exempts use_kdf, the boot-time check is listed, and the require_secure_secret
sentence is its own bullet. security.md: random secret outside production and
staging. encryption.md: stale comments. project.mdc: no hard-coded secret, the
store API and Session::Base. The Azu page names AZU_STRICT_ENVS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
@eliasjpr
eliasjpr merged commit 0294412 into master Oct 27, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants