gotify/server · error

groups claim %q contains a non-string element: %#v

Error message

groups claim %q contains a non-string element: %#v

What it means

resolvePermission accepts groups as []string, []any of strings, or a single string. If a []any element is not a string (e.g. a number or object), it fails with HTTP 500 'groups claim %q contains a non-string element'. It enforces that every group entry is a plain string before matching against GroupsAdmin/GroupsUser.

Source

Thrown at api/oidc.go:571

func (a *OIDCAPI) resolvePermission(idTokenClaims, userInfoClaims map[string]any) (bool, int, error) {
	if a.GroupsClaim == "" {
		return false, 0, nil
	}

	groupsRaw, ok := lookupClaim(a.GroupsClaim, idTokenClaims, userInfoClaims)
	if !ok {
		return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is missing", a.GroupsClaim)
	}

	var groups []string
	switch groupsRaw := groupsRaw.(type) {
	case []string:
		groups = groupsRaw
	case []any:
		for _, groupRaw := range groupsRaw {
			group, ok := groupRaw.(string)
			if !ok {
				return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q contains a non-string element: %#v", a.GroupsClaim, groupRaw)
			}
			groups = append(groups, group)
		}
	case string:
		groups = append(groups, groupsRaw)
	default:
		return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is not a string or string array: %#v", a.GroupsClaim, groupsRaw)
	}

	switch {
	case containsAny(a.GroupsAdmin, groups):
		return true, 0, nil
	case len(a.GroupsUser) == 0 || containsAny(a.GroupsUser, groups):
		return false, 0, nil
	default:
		return false, http.StatusForbidden, errors.New("user is not in any allowed group")
	}
}

View on GitHub (pinned to 14bfc25627)

Solutions

  1. Fix the IdP claim mapper to emit a plain array of strings
  2. Change the token configuration so groups are emitted as string IDs (Azure: set group claims to 'Group ID' string form)
  3. Pre-process in a custom mapper/transform, or disable object emission in the claim script
  4. Match the expected type by adjusting GroupsClaim to point at a string-array claim

Example fix

// before (Azure app manifest)
"groupMembershipClaims": "ApplicationGroup"
// after
"groupMembershipClaims": "SecurityGroup" // emits array of group ID strings
Defensive patterns

Strategy: type-guard

Validate before calling

// verify every group element is a string before login
raw, _ := claims["groups"].([]any)
for _, g := range raw {
    if _, ok := g.(string); !ok {
        log.Fatalf("groups claim has non-string element: %#v", g)
    }
}

Type guard

func isStringSlice(v any) bool {
    switch t := v.(type) {
    case []string:
        return true
    case []any:
        for _, e := range t {
            if _, ok := e.(string); !ok { return false }
        }
        return true
    default:
        return false
    }
}

Try / catch

user, status, err := resolveUser(...)
if err != nil && strings.Contains(err.Error(), "non-string element") {
    // fix IdP mapper to emit string arrays (e.g. Azure Group ID claims)
    http.Error(w, "groups claim malformed", http.StatusInternalServerError)
    return
}

Prevention

When it happens

Trigger: The groups claim is an array whose element(s) are non-strings — e.g. Azure AD sends group objects ({"id":...}) instead of IDs-as-strings, or a custom mapper emits integers/UUIDs objects.

Common situations: Azure/Entra 'groups' claim with SDP properties emitting objects; custom protocol mapper with wrong type; nested group structures from custom claim scripts.

Related errors


AI-assisted analysis of gotify/server@14bfc25627 (2026-09-05). Data as JSON: /api/errors/2909605e3652e4b4. Report an issue: GitHub.