ory/kratos · error

identity schema rejected: self-referential $ref cycle

Error message

identity schema rejected: self-referential $ref cycle: %s

What it means

preValidateSchema tracks $ref targets in a graph and detectRefCycles walks the chain of refs from each starting point. If it revisits a node already on the current chain, it reports a self-referential $ref cycle, naming the chain via formatRefCycle. Cycles are rejected because the schema compiler cannot resolve infinitely recursive references.

Solutions

  1. Break the cycle shown in the error message (the ref chain is printed): restructure one of the mutually-referencing definitions by inlining its structure or extracting the shared part into a third definition.
  2. Make the recursion truly non-cyclic by giving the recursive variant different content than what it references (actual recursion without identical pointer cycles may still be rejected here — flatten it).
  3. Use "$defs" with a clear hierarchy so each ref points strictly 'downward' (a definition must not point back at an ancestor on its own chain).
  4. Draw the ref graph (each $ref target as an edge) for complex schemas to spot cycles before submission.

Example fix

// before
"definitions": {
  "a": { "$ref": "#/definitions/b" },
  "b": { "$ref": "#/definitions/a" }   // cycle a -> b -> a
}
// after
"definitions": {
  "a": { "type": "object", "properties": { "b": { "$ref": "#/definitions/b" } } },
  "b": { "type": "object", "properties": { "name": { "type": "string" } } }   // b no longer refs back to a
Defensive patterns

Strategy: validation

Validate before calling

func hasRefCycle(defs map[string]string) bool {
    for start := range defs {
        seen := map[string]bool{}
        cur, ok := start, true
        for ok {
            if seen[cur] { return true }
            seen[cur] = true
            cur, ok = defs[cur]
        }
    }
    return false
}

Try / catch

if strings.Contains(err.Error(), "self-referential $ref cycle") {
    // parse the printed cycle chain and restructure definitions
}

Prevention

When it happens

Trigger: An identity schema's $ref graph contains a cycle, e.g. "#/definitions/a" -> "#/definitions/b" -> "#/definitions/a", or a ref pointing back to its own definition. Raised by detectRefCycles during pre-validation, before upstream compilation.

Common situations: Splitting schemas across definitions where two definitions $ref each other (mutual recursion); refactor merges that accidentally pointed a definition back at itself; copying ref paths between schemas so a relative pointer resolves to the same document.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of ory/kratos@b86338da04 (2026-09-07). Data as JSON: /api/errors/e0b1f51295bc66b6. Report an issue: GitHub.

Appendix: source

Thrown at schema/prevalidate.go:136

// contains a cycle. Walking from each `$ref` location, follow the target
// path. If the target is itself a `$ref` location, continue. If the chain
// revisits a location, the resulting `*Schema` graph has a cycle that
// crashes Validate via stack overflow.
//
// The chain ends as soon as it reaches a node that is not itself a `$ref`
// — that node has its own validation logic (`properties`, `type`, etc.)
// which consumes input on each cycle iteration, so the recursion is
// bounded. Only pure `$ref` chains form unbounded loops.
func (p *preValidator) detectRefCycles() error {
	for start := range p.refs {
		visited := map[string]struct{}{}
		cur := start
		var chain []string
		for {
			if _, ok := visited[cur]; ok {
				idx := slices.Index(chain, cur)
				cycle := append(chain[idx:], cur)
				return fmt.Errorf("identity schema rejected: self-referential $ref cycle: %s",
					formatRefCycle(cycle))
			}
			visited[cur] = struct{}{}
			chain = append(chain, cur)
			next, ok := p.refs[cur]
			if !ok {
				break
			}
			cur = next
		}
	}
	return nil
}

// escapeJSONPointer encodes a property name as a JSON-pointer reference
// token (RFC 6901): `~` → `~0`, `/` → `~1`. The order matters — `~` must be
// escaped first so a literal `/` does not collide with the escape produced
// for `~`.

View on GitHub (pinned to b86338da04)