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_SECRETstill 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):
1token, 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-byte6})7if err != nil || !token.Valid {8 c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})9 return10}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):
1# On academy-api host — check length without printing the secret value2[ ${#JWT_SECRET} -ge 32 ] && echo "Length OK: ${#JWT_SECRET} chars" || echo "WARNING: too short (${#JWT_SECRET} chars)"34# Run the same check on the GoTrue host and compare the reported lengths5# They must be equal — same characters, same length2. Missing or expired client-side token#
The frontend calls getAccessToken() (packages/common/auth.tsx:164-177) before the request:
1export async function getAccessToken() {2 if (typeof window === 'undefined') return undefined3 const { data: { session }, error } = await gotrueClient.getSession()4 if (error) throw error5 return session?.access_token6}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
expclaim
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:
1if (!session?.access_token) {2 setPrefs(null); setError(null); setLoading(false); return // exits without attempting refresh3}Fix: Attempt a session refresh before giving up:
1const { session, refreshSession } = useAuth()23if (!session?.access_token) {4 const refreshed = await refreshSession()5 if (!refreshed?.access_token) {6 setError('Session expired. Please log in again.')7 setLoading(false); return8 }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:
1grep -i "authorization\|invalid token\|unauthorized" /var/log/academy-api.log | tail -202# 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 inmain(), after config is loaded) to fail fast ifJWT_SECRETis too short. HS256 requires at least 32 bytes (256 bits) for adequate entropy:go1if len(cfg.JWTSecret) < 32 {2log.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.3JWT_SECRET=your-super-secret-key-min-32-chars - Post-migration checklist: After any service rename or re-deploy, explicitly verify
JWT_SECRETis 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.
Related Issues#
- 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.mdandbackend/AGENTS.md(backend environment and execution rules)