Skip to content

[Security] Bearer tokens are interchangeable across deployments because jwt_secret is hardcoded and shared across config templates #589

Description

@Laotree

Summary

Changing [admin].password in config.toml has no effect on whether a Bearer token issued
by one Memoh instance is accepted by another. Tokens remain valid across instances because they
are signed with [auth].jwt_secret, and every shipped config template hardcodes the same
static placeholder secret
.

Root cause

JWT signing/verification depends only on [auth].jwt_secret (HS256, see
internal/auth/jwt.go:GenerateToken / JWTMiddleware). Token claims contain only the account
UUID (sub/user_id) plus iat/exp — nothing derived from the admin password, username, or
any per-deployment value ([admin].password is used solely for bcrypt verification at login,
see cmd/agent/app.go:ensureAdminUser).

The shipped templates all ship with fixed, repeated jwt_secret values, and there is no code
path that generates or persists a random secret per install:

config.toml:17 jwt_secret = "YZq8kXrW5dFpNt9mLxQvHbRjKsMnOePw"
conf/app.docker.toml:17 jwt_secret = "YZq8kXrW5dFpNt9mLxQvHbRjKsMnOePw" # identical
conf/app.apple.toml:17 / app.windows.toml:17 → "memoh-dev-secret-do-not-use-in-production"
conf/app.local.toml:18 → "LOCAL-DEV-CHANGE-ME"
conf/app.example.toml:18 → "CHANGE-ME-TO-A-RANDOM-SECRET"

So any two deployments that copy a template without manually changing jwt_secret (an easy
thing to miss — operators naturally focus on [admin].password) end up with identical HMAC
signing keys
, making their Bearer tokens mutually valid.

Risk / Impact

  • Cross-instance token reuse: a token minted on instance A authenticates successfully on
    instance B (and vice versa) whenever both share a default jwt_secret.
  • Token forgery: because these placeholder secrets are committed in a public repo, anyone
    who has seen the source can compute HMAC-SHA256(claims, "<placeholder secret>") themselves
    and forge a valid Bearer token for an arbitrary user_id without ever logging in — a full
    authentication bypass for any deployment that didn't override jwt_secret.
  • This is worse than a weak default password, because the existing safeguard for that
    (cmd/agent/app.go:1110-1112, which warns when [admin].password == "change-your-password-here")
    has no equivalent for jwt_secret.

Steps to reproduce

  1. Stand up two Memoh instances from the shipped config.toml (or conf/app.docker.toml),
    changing only [admin].username/[admin].password between them.
  2. Log in to instance A, capture the returned Bearer token.
  3. Send an authenticated request to instance B using instance A's token.
  4. Observe instance B accepts the token despite different admin credentials.

Suggested fix (any of)

  1. Generate a cryptographically random jwt_secret on first boot when empty/unset, persist it,
    and refuse to fall back to a baked-in default.
  2. At minimum, mirror the existing change-your-password-here check (cmd/agent/app.go:1110-1112):
    detect when jwt_secret matches one of the known shipped placeholder values and refuse to
    start (or loudly warn).
  3. Stop shipping a real secret value in templates; ship "" and fail fast at boot
    (internal/boot/runtime.go:ProvideRuntimeConfig already errors on an empty secret), forcing
    operators to generate their own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions