Services

Academy Jwt Secret Mismatch 2026 04 18


"Failed to load preferences: 401" — GoTrue JWT secret mismatch with academy-api

Problem#

The academy app notification settings page fails to load with "Failed to load preferences: 401" when the GET /api/v1/preferences/notifications request is rejected by the academy-api JWT middleware. The root cause is a mismatch between the secret GoTrue uses to sign access tokens and the JWT_SECRET environment variable configured for academy-api — these two values must be identical for token verification to succeed.

Symptoms#

  • User sees "Failed to load preferences: 401" on the notifications settings page at https://learn.assistance.dev.assistance.bg/
  • Browser Network tab shows a 401 response for GET /api/v1/preferences/notifications
  • Response body contains {"error": "invalid token"}, {"error": "authorization header required"}, or {"error": "invalid authorization format"}
  • The Authorization: Bearer <token> header may be present but still rejected
  • Server logs show auth middleware rejecting at backend/internal/shared/middleware/middleware.go:90-133

What Didn't Work#

  • Checking only client-side token presence: A non-empty token doesn't mean the signature is valid against the backend secret.
  • Restarting academy-api without verifying secrets: Restarting has no effect if JWT_SECRET still doesn't match GoTrue's signing secret.
  • Assuming CORS: A CORS error would be distinct; 401 means the token was received but rejected during verification.

Solution#

There are four possible causes; work through them in order:

1. JWT secret mismatch (most common)#

The academy-api validates tokens using cfg.JWTSecret (from the JWT_SECRET env var). This must exactly match GoTrue's signing secret — byte for byte.

Where validation happens (backend/internal/shared/middleware/middleware.go:104-112):

go
1
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
2
if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
3
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
4
}
5
return []byte(jwtSecret), nil // ← must match GoTrue's secret, byte-for-byte
6
})
7
if err != nil || !token.Valid {
8
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
9
return
10
}

Fix: Ensure the JWT_SECRET on the academy-api server is identical to the GoTrue JWT_SECRET. Verify length without echoing the secret (echoing writes it to shell history):

bash
1
# On academy-api host — check length without printing the secret value
2
[ ${#JWT_SECRET} -ge 32 ] && echo "Length OK: ${#JWT_SECRET} chars" || echo "WARNING: too short (${#JWT_SECRET} chars)"
3
4
# Run the same check on the GoTrue host and compare the reported lengths
5
# They must be equal — same characters, same length

2. Missing or expired client-side token#

The frontend calls getAccessToken() (packages/common/auth.tsx:164-177) before the request:

ts
1
export async function getAccessToken() {
2
if (typeof window === 'undefined') return undefined
3
const { data: { session }, error } = await gotrueClient.getSession()
4
if (error) throw error
5
return session?.access_token
6
}

Fix: In browser DevTools → Network tab, find the failing request and inspect the Authorization header:

  • Header missing or empty → GoTrue session is expired; user must re-login
  • Header present → decode the JWT at jwt.io and check the exp claim

3. Expired session (missing refresh)#

The page checks for an active session before fetching (apps/academy/app/(protected)/settings/notifications/page.tsx:46-50) but doesn't proactively refresh:

tsx
1
if (!session?.access_token) {
2
setPrefs(null); setError(null); setLoading(false); return // exits without attempting refresh
3
}

Fix: Attempt a session refresh before giving up:

tsx
1
const { session, refreshSession } = useAuth()
2
3
if (!session?.access_token) {
4
const refreshed = await refreshSession()
5
if (!refreshed?.access_token) {
6
setError('Session expired. Please log in again.')
7
setLoading(false); return
8
}
9
}

4. Diagnose specific server-side rejection#

If the token is present and non-expired on the client but still returns 401, read academy-api logs for the exact rejection reason:

Log messageCauseFix
"authorization header required"No Authorization header sentCheck fetch call at page.tsx:58 — ensure token is being attached
"invalid authorization format"Missing Bearer prefixToken returned by getAccessToken() may be malformed
"unexpected signing method"Token isn't HS256GoTrue is configured with a different algorithm (e.g. RS256). academy-api's middleware only accepts HS256 — align both services to the same algorithm before changing the secret
"invalid token"Signature mismatchJWT_SECRET mismatch (step 1) or tampered token
bash
1
grep -i "authorization\|invalid token\|unauthorized" /var/log/academy-api.log | tail -20
2
# Path varies — adjust for your deployment (Docker: docker logs academy-api, systemd: journalctl -u academy-api)

Why This Works#

JWT authentication uses symmetric signing (HS256). GoTrue signs the access_token with its JWT_SECRET. When academy-api receives the token, it recomputes the HMAC over the header+payload using its own jwtSecret. If the two secrets differ by even one character, the computed HMAC won't match the signature in the token — and the middleware returns 401 with "invalid token".

The fix ensures both sides share the identical secret so signature verification passes, allowing the middleware to extract the sub claim as user_id and the request to proceed.

Prevention#

  • Add startup validation in backend/cmd/academy-api/main.go (early in main(), after config is loaded) to fail fast if JWT_SECRET is too short. HS256 requires at least 32 bytes (256 bits) for adequate entropy:
    go
    1
    if len(cfg.JWTSecret) < 32 {
    2
    log.Fatalf("JWT_SECRET must be >= 32 bytes (256 bits for HS256), got %d", len(cfg.JWTSecret))
    3
    }
  • Document in .env.example:
    1
    # Must be identical to GoTrue's JWT signing secret. Min 32 bytes (256 bits for HS256).
    2
    # Never commit this value — use a secrets manager (Vault, AWS Secrets Manager) in production.
    3
    JWT_SECRET=your-super-secret-key-min-32-chars
  • Post-migration checklist: After any service rename or re-deploy, explicitly verify JWT_SECRET is consistent across GoTrue and all protected backend services (auth-api, academy-api, billing-api).
  • Monitoring: Alert if academy-api logs "invalid token" errors spike — this typically indicates secret drift after a deploy.
  • No prior GitHub issues found for this specific error.
  • Related files: backend/internal/shared/middleware/middleware.go:90-133, packages/common/auth.tsx:164-177, apps/academy/app/(protected)/settings/notifications/page.tsx:56-65
  • Related docs: docs/migrations/2026-04-05-learn-to-academy.md (service rename that may introduce secret drift), backend/README.md and backend/AGENTS.md (backend environment and execution rules)