juanfont/headscale · critical

creating oauth_access_tokens index: %w

Error message

creating oauth_access_tokens index: %w

What it means

The oauth migration fails creating unique index idx_oauth_access_tokens_prefix on oauth_access_tokens(prefix). The table is freshly created in the same transaction so duplicates cannot exist; realistic causes are missing INDEX privilege, an aborted Postgres transaction from an earlier statement, or lock failure. Like its oauth_clients twin, this error is frequently the visible tail of an earlier failure in the same migration.

Source

Thrown at hscontrol/db/db.go:895

					if !tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
						err := tx.Exec(`CREATE TABLE oauth_access_tokens(
  id integer PRIMARY KEY AUTOINCREMENT,
  prefix text,
  hash blob,
  client_id text,
  scopes text,
  tags text,
  expiration datetime,
  created_at datetime
)`).Error
						if err != nil {
							return fmt.Errorf("creating oauth_access_tokens table: %w", err)
						}

						err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`).Error
						if err != nil {
							return fmt.Errorf("creating oauth_access_tokens index: %w", err)
						}
					}

					return nil
				},
				Rollback: func(db *gorm.DB) error { return nil },
			},
			{
				// Clear stale key expiry on tagged nodes. A tagged node is
				// owned by its tags and never expires (KB 1068), but a buggy
				// handleLogout stamped a past expiry on it, leaving it
				// permanently Expired and unable to re-authenticate. The
				// buggy writer is fixed, so this only repairs rows written
				// before the upgrade; a fixed server cannot recreate them.
				// Match the tagged-node predicate the earlier
				// clear-tagged-node-user-id migration uses (a nil tags slice
				// marshals to 'null', so exclude it).
				// Fixes: https://github.com/juanfont/headscale/issues/3371

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Find the first error in the same migration in the log and fix that; this index error usually disappears with it
  2. Grant the role CREATE INDEX (Postgres) or otherwise clear the wrapped permission error
  3. Restart headscale after the fix - the guards make the migration idempotent
  4. Verify: query pg_indexes (Postgres) or sqlite_master (SQLite) for idx_oauth_access_tokens_prefix
Defensive patterns

Strategy: try-catch

Validate before calling

// Postgres: verify index-creation privilege ahead of the upgrade
var can bool
db.QueryRow("SELECT has_schema_privilege(current_user, current_schema(), 'CREATE')").Scan(&can)
if !can {
	log.Fatal("role cannot create indexes in this schema")
}

Try / catch

// Treat as symptom: on Postgres this usually means the migration transaction was
// already aborted; catch, unwrap fully, and surface the first failure
if err := runMigrations(db); err != nil {
	for e := err; e != nil; e = errors.Unwrap(e) {
		log.Error().Err(e).Msg("migration error chain")
	}
	os.Exit(1)
}

Prevention

When it happens

Trigger: Executing CREATE UNIQUE INDEX after a prior statement in the migration transaction already failed (Postgres abort semantics), or with CREATE INDEX revoked / database locked.

Common situations: Diagnosing the last line of a failed migration instead of the first; constrained roles; concurrent SQLite writers.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/3dcf53e98458d3b8. Report an issue: GitHub.