Skip to content

security: secretless deployments accept admin tokens forged with the empty-string HMAC key #291

Description

@EricAndrechek

Summary

When neither auth.jwt_secret nor auth.jwks_url is configured — the default deployments/compose/standalone.yaml quickstart posture — the JWT middleware verifies HMAC tokens against []byte(""). An empty HMAC key is a valid key, so anyone can mint {"role":"admin"} signed with the empty string, pass RequireAdmin, and reach /v1/admin/query (raw SQL), policy CRUD, schema, and DLQ. Secretless mode is documented and logged as "pure public / default_role only" — it is actually forgeable-admin.

This contradicts the startup WARN, several code comments, and (newly, on PR #290) the docs.

Root cause

internal/auth/auth.go:70-92:

keyFunc := func(t *jwt.Token) (any, error) {
    if jwks != nil { return jwks.Keyfunc(t) }
    if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid }
    return []byte(cfg.JWTSecret), nil   // cfg.JWTSecret == "" here → empty HMAC key
}
// validMethods = HS256/384/512 when jwks == nil

jwt.Parse with WithValidMethods(["HS256",...]) and an empty-key keyFunc accepts a token an attacker signed with "". Tokenless requests correctly fall to default_role; the hole is specifically a presented, forged token escalating past it.

Proof (golang-jwt v5.3.1, the pinned version)

Using the exact keyFunc + WithValidMethods shape from the middleware:

forge err: <nil>
verify err: <nil> | token.Valid: true
ACCEPTED with role: admin

(token = jwt.NewWithClaims(HS256, {"role":"admin","exp":9999999999}).SignedString([]byte("")))

Exposure

deployments/compose/standalone.yaml publishes 8080:8080, sets no WH_AUTH_JWT_SECRET, and mounts no config.yaml — so a default docker compose up of the quickstart is reachable-admin to anyone who can hit the port.

Recommended fix (needs security review — fail-closed invariant, AGENTS.md #7/#13)

In internal/auth.Middleware, when jwks == nil && cfg.JWTSecret == "", treat every presented token as unverifiable — route it through the existing bad-token path (records the token error → resolves to default_role → fail-loud 401 on a gated denial). Tokenless requests are unchanged, so no documented public flow breaks; secretless mode becomes genuinely "public, default_role only" as everything already claims.

After the code is fixed, these stale claims become true as written and can be closed out:

  • code: internal/auth/auth.go:40-42, internal/config/config.go:119, the WARN at cmd/wavehouse/main.go:76, config.yaml, the comment in deployments/compose/standalone.yaml
  • docs: docs/src/content/docs/access-control.md:43, configuration.md:81, getting-started.md:54,103, deployment.md:126 (all pre-existing; PR feat(docs): live-demo hero panel via @wavehouse/sdk #290 deliberately did not add a sixth — it removed the one new instance it had introduced and tracks the rest here)

Suggested hardening (separate)

Also warn/refuse at startup if default_role resolves to an admin-capable role, and consider rejecting the change-me-in-production placeholder in non-dev.

Found during the PR #290 docs-review gate; surfaced to the maintainer, who chose to track it here and keep the (unrelated) docs PR moving.

Activity

  1. added
    bugSomething isn't working
    area/apiHTTP handlers, routing, middleware
    securitySecurity-sensitive issue or fix
    on Jun 6, 2026
  2. coderabbitai commented on Jun 6, 2026

    @coderabbitai
    🔗 Related PRs

    #123 - fix(api): drop CORS credentials + skip same-origin decoration [merged]
    #137 - feat(deploy): local dev o11y stack (#121) [merged]
    #172 - feat(rbac)!: fail-closed authorization + default_role public access [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  3. EricAndrechek commented on Jun 8, 2026

    @EricAndrechek
    MemberAuthor

    Cross-link: this is the acute, empty-secret instance of #228's "any holder of the HMAC secret can mint admin" bullet. #228 tracks the general production-hardening class (post-alpha); this one is P0/pre-flip because the default secretless quickstart needs no secret holder at all.

    🤖 Posted autonomously via /pm-triage with Claude Code

  4. taitelee commented on Jun 8, 2026

    @taitelee
    Member

    We warn loudly on jwt secrets that are not configured or empty. The admin role is forgeable in this case, but that is included in the assumption of the warn message where no token can be validated without a secret or jwks url set. This would be a configuration decision on the client's end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiHTTP handlers, routing, middlewarearea/policyAccess control policies (Hasura-style)bugSomething isn't workingsecuritySecurity-sensitive issue or fix

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions